@usejarvis/brain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/LICENSE +153 -0
  2. package/README.md +278 -0
  3. package/bin/jarvis.ts +413 -0
  4. package/package.json +74 -0
  5. package/scripts/ensure-bun.cjs +8 -0
  6. package/src/actions/README.md +421 -0
  7. package/src/actions/app-control/desktop-controller.test.ts +26 -0
  8. package/src/actions/app-control/desktop-controller.ts +438 -0
  9. package/src/actions/app-control/interface.ts +64 -0
  10. package/src/actions/app-control/linux.ts +273 -0
  11. package/src/actions/app-control/macos.ts +54 -0
  12. package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
  13. package/src/actions/app-control/sidecar-launcher.ts +286 -0
  14. package/src/actions/app-control/windows.ts +44 -0
  15. package/src/actions/browser/cdp.ts +138 -0
  16. package/src/actions/browser/chrome-launcher.ts +252 -0
  17. package/src/actions/browser/session.ts +437 -0
  18. package/src/actions/browser/stealth.ts +49 -0
  19. package/src/actions/index.ts +20 -0
  20. package/src/actions/terminal/executor.ts +157 -0
  21. package/src/actions/terminal/wsl-bridge.ts +126 -0
  22. package/src/actions/test.ts +93 -0
  23. package/src/actions/tools/agents.ts +321 -0
  24. package/src/actions/tools/builtin.ts +846 -0
  25. package/src/actions/tools/commitments.ts +192 -0
  26. package/src/actions/tools/content.ts +217 -0
  27. package/src/actions/tools/delegate.ts +147 -0
  28. package/src/actions/tools/desktop.test.ts +55 -0
  29. package/src/actions/tools/desktop.ts +305 -0
  30. package/src/actions/tools/goals.ts +376 -0
  31. package/src/actions/tools/local-tools-guard.ts +20 -0
  32. package/src/actions/tools/registry.ts +171 -0
  33. package/src/actions/tools/research.ts +111 -0
  34. package/src/actions/tools/sidecar-list.ts +57 -0
  35. package/src/actions/tools/sidecar-route.ts +105 -0
  36. package/src/actions/tools/workflows.ts +216 -0
  37. package/src/agents/agent.ts +132 -0
  38. package/src/agents/delegation.ts +107 -0
  39. package/src/agents/hierarchy.ts +113 -0
  40. package/src/agents/index.ts +19 -0
  41. package/src/agents/messaging.ts +125 -0
  42. package/src/agents/orchestrator.ts +576 -0
  43. package/src/agents/role-discovery.ts +61 -0
  44. package/src/agents/sub-agent-runner.ts +307 -0
  45. package/src/agents/task-manager.ts +151 -0
  46. package/src/authority/approval-delivery.ts +59 -0
  47. package/src/authority/approval.ts +196 -0
  48. package/src/authority/audit.ts +158 -0
  49. package/src/authority/authority.test.ts +519 -0
  50. package/src/authority/deferred-executor.ts +103 -0
  51. package/src/authority/emergency.ts +66 -0
  52. package/src/authority/engine.ts +297 -0
  53. package/src/authority/index.ts +12 -0
  54. package/src/authority/learning.ts +111 -0
  55. package/src/authority/tool-action-map.ts +74 -0
  56. package/src/awareness/analytics.ts +466 -0
  57. package/src/awareness/awareness.test.ts +332 -0
  58. package/src/awareness/capture-engine.ts +305 -0
  59. package/src/awareness/context-graph.ts +130 -0
  60. package/src/awareness/context-tracker.ts +349 -0
  61. package/src/awareness/index.ts +25 -0
  62. package/src/awareness/intelligence.ts +321 -0
  63. package/src/awareness/ocr-engine.ts +88 -0
  64. package/src/awareness/service.ts +528 -0
  65. package/src/awareness/struggle-detector.ts +342 -0
  66. package/src/awareness/suggestion-engine.ts +476 -0
  67. package/src/awareness/types.ts +201 -0
  68. package/src/cli/autostart.ts +241 -0
  69. package/src/cli/deps.ts +449 -0
  70. package/src/cli/doctor.ts +230 -0
  71. package/src/cli/helpers.ts +401 -0
  72. package/src/cli/onboard.ts +580 -0
  73. package/src/comms/README.md +329 -0
  74. package/src/comms/auth-error.html +48 -0
  75. package/src/comms/channels/discord.ts +228 -0
  76. package/src/comms/channels/signal.ts +56 -0
  77. package/src/comms/channels/telegram.ts +316 -0
  78. package/src/comms/channels/whatsapp.ts +60 -0
  79. package/src/comms/channels.test.ts +173 -0
  80. package/src/comms/desktop-notify.ts +114 -0
  81. package/src/comms/example.ts +129 -0
  82. package/src/comms/index.ts +129 -0
  83. package/src/comms/streaming.ts +142 -0
  84. package/src/comms/voice.test.ts +152 -0
  85. package/src/comms/voice.ts +291 -0
  86. package/src/comms/websocket.test.ts +409 -0
  87. package/src/comms/websocket.ts +473 -0
  88. package/src/config/README.md +387 -0
  89. package/src/config/index.ts +6 -0
  90. package/src/config/loader.test.ts +137 -0
  91. package/src/config/loader.ts +142 -0
  92. package/src/config/types.ts +260 -0
  93. package/src/daemon/README.md +232 -0
  94. package/src/daemon/agent-service-interface.ts +9 -0
  95. package/src/daemon/agent-service.ts +600 -0
  96. package/src/daemon/api-routes.ts +2119 -0
  97. package/src/daemon/background-agent-service.ts +396 -0
  98. package/src/daemon/background-agent.test.ts +78 -0
  99. package/src/daemon/channel-service.ts +201 -0
  100. package/src/daemon/commitment-executor.ts +297 -0
  101. package/src/daemon/event-classifier.ts +239 -0
  102. package/src/daemon/event-coalescer.ts +123 -0
  103. package/src/daemon/event-reactor.ts +214 -0
  104. package/src/daemon/health.ts +220 -0
  105. package/src/daemon/index.ts +1004 -0
  106. package/src/daemon/llm-settings.ts +316 -0
  107. package/src/daemon/observer-service.ts +150 -0
  108. package/src/daemon/pid.ts +98 -0
  109. package/src/daemon/research-queue.ts +155 -0
  110. package/src/daemon/services.ts +175 -0
  111. package/src/daemon/ws-service.ts +788 -0
  112. package/src/goals/accountability.ts +240 -0
  113. package/src/goals/awareness-bridge.ts +185 -0
  114. package/src/goals/estimator.ts +185 -0
  115. package/src/goals/events.ts +28 -0
  116. package/src/goals/goals.test.ts +400 -0
  117. package/src/goals/integration.test.ts +329 -0
  118. package/src/goals/nl-builder.test.ts +220 -0
  119. package/src/goals/nl-builder.ts +256 -0
  120. package/src/goals/rhythm.test.ts +177 -0
  121. package/src/goals/rhythm.ts +275 -0
  122. package/src/goals/service.test.ts +135 -0
  123. package/src/goals/service.ts +348 -0
  124. package/src/goals/types.ts +106 -0
  125. package/src/goals/workflow-bridge.ts +96 -0
  126. package/src/integrations/google-api.ts +134 -0
  127. package/src/integrations/google-auth.ts +175 -0
  128. package/src/llm/README.md +291 -0
  129. package/src/llm/anthropic.ts +386 -0
  130. package/src/llm/gemini.ts +371 -0
  131. package/src/llm/index.ts +19 -0
  132. package/src/llm/manager.ts +153 -0
  133. package/src/llm/ollama.ts +307 -0
  134. package/src/llm/openai.ts +350 -0
  135. package/src/llm/provider.test.ts +231 -0
  136. package/src/llm/provider.ts +60 -0
  137. package/src/llm/test.ts +87 -0
  138. package/src/observers/README.md +278 -0
  139. package/src/observers/calendar.ts +113 -0
  140. package/src/observers/clipboard.ts +136 -0
  141. package/src/observers/email.ts +109 -0
  142. package/src/observers/example.ts +58 -0
  143. package/src/observers/file-watcher.ts +124 -0
  144. package/src/observers/index.ts +159 -0
  145. package/src/observers/notifications.ts +197 -0
  146. package/src/observers/observers.test.ts +203 -0
  147. package/src/observers/processes.ts +225 -0
  148. package/src/personality/README.md +61 -0
  149. package/src/personality/adapter.ts +196 -0
  150. package/src/personality/index.ts +20 -0
  151. package/src/personality/learner.ts +209 -0
  152. package/src/personality/model.ts +132 -0
  153. package/src/personality/personality.test.ts +236 -0
  154. package/src/roles/README.md +252 -0
  155. package/src/roles/authority.ts +119 -0
  156. package/src/roles/example-usage.ts +198 -0
  157. package/src/roles/index.ts +42 -0
  158. package/src/roles/loader.ts +143 -0
  159. package/src/roles/prompt-builder.ts +194 -0
  160. package/src/roles/test-multi.ts +102 -0
  161. package/src/roles/test-role.yaml +77 -0
  162. package/src/roles/test-utils.ts +93 -0
  163. package/src/roles/test.ts +106 -0
  164. package/src/roles/tool-guide.ts +190 -0
  165. package/src/roles/types.ts +36 -0
  166. package/src/roles/utils.ts +200 -0
  167. package/src/scripts/google-setup.ts +168 -0
  168. package/src/sidecar/connection.ts +179 -0
  169. package/src/sidecar/index.ts +6 -0
  170. package/src/sidecar/manager.ts +542 -0
  171. package/src/sidecar/protocol.ts +85 -0
  172. package/src/sidecar/rpc.ts +161 -0
  173. package/src/sidecar/scheduler.ts +136 -0
  174. package/src/sidecar/types.ts +112 -0
  175. package/src/sidecar/validator.ts +144 -0
  176. package/src/vault/README.md +110 -0
  177. package/src/vault/awareness.ts +341 -0
  178. package/src/vault/commitments.ts +299 -0
  179. package/src/vault/content-pipeline.ts +260 -0
  180. package/src/vault/conversations.ts +173 -0
  181. package/src/vault/entities.ts +180 -0
  182. package/src/vault/extractor.test.ts +356 -0
  183. package/src/vault/extractor.ts +345 -0
  184. package/src/vault/facts.ts +190 -0
  185. package/src/vault/goals.ts +477 -0
  186. package/src/vault/index.ts +87 -0
  187. package/src/vault/keychain.ts +99 -0
  188. package/src/vault/observations.ts +115 -0
  189. package/src/vault/relationships.ts +178 -0
  190. package/src/vault/retrieval.test.ts +126 -0
  191. package/src/vault/retrieval.ts +227 -0
  192. package/src/vault/schema.ts +658 -0
  193. package/src/vault/settings.ts +38 -0
  194. package/src/vault/vectors.ts +92 -0
  195. package/src/vault/workflows.ts +403 -0
  196. package/src/workflows/auto-suggest.ts +290 -0
  197. package/src/workflows/engine.ts +366 -0
  198. package/src/workflows/events.ts +24 -0
  199. package/src/workflows/executor.ts +207 -0
  200. package/src/workflows/nl-builder.ts +198 -0
  201. package/src/workflows/nodes/actions/agent-task.ts +73 -0
  202. package/src/workflows/nodes/actions/calendar-action.ts +85 -0
  203. package/src/workflows/nodes/actions/code-execution.ts +73 -0
  204. package/src/workflows/nodes/actions/discord.ts +77 -0
  205. package/src/workflows/nodes/actions/file-write.ts +73 -0
  206. package/src/workflows/nodes/actions/gmail.ts +69 -0
  207. package/src/workflows/nodes/actions/http-request.ts +117 -0
  208. package/src/workflows/nodes/actions/notification.ts +85 -0
  209. package/src/workflows/nodes/actions/run-tool.ts +55 -0
  210. package/src/workflows/nodes/actions/send-message.ts +82 -0
  211. package/src/workflows/nodes/actions/shell-command.ts +76 -0
  212. package/src/workflows/nodes/actions/telegram.ts +60 -0
  213. package/src/workflows/nodes/builtin.ts +119 -0
  214. package/src/workflows/nodes/error/error-handler.ts +37 -0
  215. package/src/workflows/nodes/error/fallback.ts +47 -0
  216. package/src/workflows/nodes/error/retry.ts +82 -0
  217. package/src/workflows/nodes/logic/delay.ts +42 -0
  218. package/src/workflows/nodes/logic/if-else.ts +41 -0
  219. package/src/workflows/nodes/logic/loop.ts +90 -0
  220. package/src/workflows/nodes/logic/merge.ts +38 -0
  221. package/src/workflows/nodes/logic/race.ts +40 -0
  222. package/src/workflows/nodes/logic/switch.ts +59 -0
  223. package/src/workflows/nodes/logic/template-render.ts +53 -0
  224. package/src/workflows/nodes/logic/variable-get.ts +37 -0
  225. package/src/workflows/nodes/logic/variable-set.ts +59 -0
  226. package/src/workflows/nodes/registry.ts +99 -0
  227. package/src/workflows/nodes/transform/aggregate.ts +99 -0
  228. package/src/workflows/nodes/transform/csv-parse.ts +70 -0
  229. package/src/workflows/nodes/transform/json-parse.ts +63 -0
  230. package/src/workflows/nodes/transform/map-filter.ts +84 -0
  231. package/src/workflows/nodes/transform/regex-match.ts +89 -0
  232. package/src/workflows/nodes/triggers/calendar.ts +33 -0
  233. package/src/workflows/nodes/triggers/clipboard.ts +32 -0
  234. package/src/workflows/nodes/triggers/cron.ts +40 -0
  235. package/src/workflows/nodes/triggers/email.ts +40 -0
  236. package/src/workflows/nodes/triggers/file-change.ts +45 -0
  237. package/src/workflows/nodes/triggers/git.ts +46 -0
  238. package/src/workflows/nodes/triggers/manual.ts +23 -0
  239. package/src/workflows/nodes/triggers/poll.ts +81 -0
  240. package/src/workflows/nodes/triggers/process.ts +44 -0
  241. package/src/workflows/nodes/triggers/screen-event.ts +37 -0
  242. package/src/workflows/nodes/triggers/webhook.ts +39 -0
  243. package/src/workflows/safe-eval.ts +139 -0
  244. package/src/workflows/template.ts +118 -0
  245. package/src/workflows/triggers/cron.ts +311 -0
  246. package/src/workflows/triggers/manager.ts +285 -0
  247. package/src/workflows/triggers/observer-bridge.ts +172 -0
  248. package/src/workflows/triggers/poller.ts +201 -0
  249. package/src/workflows/triggers/screen-condition.ts +218 -0
  250. package/src/workflows/triggers/triggers.test.ts +740 -0
  251. package/src/workflows/triggers/webhook.ts +191 -0
  252. package/src/workflows/types.ts +133 -0
  253. package/src/workflows/variables.ts +72 -0
  254. package/src/workflows/workflows.test.ts +383 -0
  255. package/src/workflows/yaml.ts +104 -0
  256. package/ui/dist/index-j75njzc1.css +1199 -0
  257. package/ui/dist/index-p2zh407q.js +80603 -0
  258. package/ui/dist/index.html +13 -0
  259. package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
  260. package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
  261. package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
  262. package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
  263. package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
  264. package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
  265. package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
  266. package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
@@ -0,0 +1,2119 @@
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 { findCommitments, getUpcoming, createCommitment, getCommitment, updateCommitmentStatus, reorderCommitments } from '../vault/commitments.ts';
28
+ import { getOrCreateConversation, getMessages, getRecentConversation } from '../vault/conversations.ts';
29
+ import { getRecentObservations } from '../vault/observations.ts';
30
+ import { getPersonality } from '../personality/model.ts';
31
+ import {
32
+ createContent, getContent, findContent, updateContent, deleteContent,
33
+ advanceStage, regressStage,
34
+ addStageNote, getStageNotes,
35
+ addAttachment, getAttachments, deleteAttachment,
36
+ CONTENT_STAGES, CONTENT_TYPES,
37
+ } from '../vault/content-pipeline.ts';
38
+
39
+ import { mkdirSync, existsSync } from 'node:fs';
40
+ import path from 'node:path';
41
+ import os from 'node:os';
42
+
43
+ import type { WebSocketService } from './ws-service.ts';
44
+ import type { ChannelService } from './channel-service.ts';
45
+
46
+ import type { AwarenessService } from '../awareness/service.ts';
47
+ import { readFileSync } from 'node:fs';
48
+ import {
49
+ getCapture,
50
+ getRecentCaptures,
51
+ getCapturesInRange,
52
+ } from '../vault/awareness.ts';
53
+ import type { SuggestionType } from '../awareness/types.ts';
54
+
55
+ export type ApiContext = {
56
+ healthMonitor: HealthMonitor;
57
+ agentService: AgentService;
58
+ config: JarvisConfig;
59
+ wsService?: WebSocketService;
60
+ channelService?: ChannelService;
61
+ authorityEngine?: AuthorityEngine;
62
+ approvalManager?: ApprovalManager;
63
+ auditTrail?: AuditTrail;
64
+ learner?: AuthorityLearner;
65
+ emergencyController?: EmergencyController;
66
+ deferredExecutor?: DeferredExecutor;
67
+ awarenessService?: AwarenessService | null;
68
+ workflowEngine?: import('../workflows/engine.ts').WorkflowEngine;
69
+ triggerManager?: import('../workflows/triggers/manager.ts').TriggerManager;
70
+ webhookManager?: import('../workflows/triggers/webhook.ts').WebhookManager;
71
+ nodeRegistry?: import('../workflows/nodes/registry.ts').NodeRegistry;
72
+ nlBuilder?: import('../workflows/nl-builder.ts').NLWorkflowBuilder;
73
+ autoSuggest?: import('../workflows/auto-suggest.ts').WorkflowAutoSuggest;
74
+ goalService?: import('../goals/service.ts').GoalService;
75
+ sidecarManager?: import('../sidecar/manager.ts').SidecarManager;
76
+ };
77
+
78
+ // CORS headers for dashboard
79
+ const CORS = {
80
+ 'Access-Control-Allow-Origin': '*',
81
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
82
+ 'Access-Control-Allow-Headers': 'Content-Type',
83
+ };
84
+
85
+ function json(data: unknown, status = 200): Response {
86
+ return Response.json(data, { status, headers: CORS });
87
+ }
88
+
89
+ function error(message: string, status = 400): Response {
90
+ return json({ error: message }, status);
91
+ }
92
+
93
+ function getSearchParams(req: Request): URLSearchParams {
94
+ return new URL(req.url).searchParams;
95
+ }
96
+
97
+ /**
98
+ * Create all API route handlers.
99
+ */
100
+ export function createApiRoutes(ctx: ApiContext): Record<string, unknown> {
101
+ return {
102
+ // --- Health ---
103
+ '/api/health': {
104
+ GET: () => json(ctx.healthMonitor.getHealth()),
105
+ },
106
+
107
+ // --- Vault: Entities ---
108
+ '/api/vault/entities': {
109
+ GET: (req: Request) => {
110
+ const params = getSearchParams(req);
111
+ const type = params.get('type') as EntityType | null;
112
+ const q = params.get('q');
113
+ const query: { type?: EntityType; nameContains?: string } = {};
114
+ if (type) query.type = type;
115
+ if (q) query.nameContains = q;
116
+ return json(findEntities(query));
117
+ },
118
+ },
119
+
120
+ '/api/vault/entities/:id': {
121
+ GET: (req: Request & { params: { id: string } }) => {
122
+ const entity = getEntity(req.params.id);
123
+ if (!entity) return error('Entity not found', 404);
124
+ return json(entity);
125
+ },
126
+ },
127
+
128
+ '/api/vault/entities/:id/facts': {
129
+ GET: (req: Request & { params: { id: string } }) => {
130
+ return json(findFacts({ subject_id: req.params.id }));
131
+ },
132
+ },
133
+
134
+ '/api/vault/entities/:id/relationships': {
135
+ GET: (req: Request & { params: { id: string } }) => {
136
+ return json(getEntityRelationships(req.params.id));
137
+ },
138
+ },
139
+
140
+ // --- Vault: Facts ---
141
+ '/api/vault/facts': {
142
+ GET: (req: Request) => {
143
+ const params = getSearchParams(req);
144
+ const query: { subject_id?: string; predicate?: string; object?: string } = {};
145
+ const subjectId = params.get('subject_id');
146
+ const predicate = params.get('predicate');
147
+ const object = params.get('object');
148
+ if (subjectId) query.subject_id = subjectId;
149
+ if (predicate) query.predicate = predicate;
150
+ if (object) query.object = object;
151
+ return json(findFacts(query));
152
+ },
153
+ },
154
+
155
+ // --- Vault: Relationships ---
156
+ '/api/vault/relationships': {
157
+ GET: (req: Request) => {
158
+ const params = getSearchParams(req);
159
+ const query: { from_id?: string; to_id?: string; type?: string } = {};
160
+ const fromId = params.get('from_id');
161
+ const toId = params.get('to_id');
162
+ const type = params.get('type');
163
+ if (fromId) query.from_id = fromId;
164
+ if (toId) query.to_id = toId;
165
+ if (type) query.type = type;
166
+ return json(findRelationships(query));
167
+ },
168
+ },
169
+
170
+ // --- Vault: Unified Search ---
171
+ '/api/vault/search': {
172
+ GET: (req: Request) => {
173
+ const params = getSearchParams(req);
174
+ const q = params.get('q')?.trim() || '';
175
+ const type = params.get('type') as EntityType | null;
176
+ const limit = Math.min(parseInt(params.get('limit') ?? '50') || 50, 200);
177
+
178
+ const db = getDb();
179
+ const entityIds = new Set<string>();
180
+
181
+ if (q) {
182
+ // 1. Search entities by name
183
+ const nameMatches = searchEntitiesByName(q);
184
+ for (const e of nameMatches) entityIds.add(e.id);
185
+
186
+ // 2. Search facts by predicate or object
187
+ const factRows = db.prepare(
188
+ 'SELECT DISTINCT subject_id FROM facts WHERE predicate LIKE ? OR object LIKE ? LIMIT 200'
189
+ ).all(`%${q}%`, `%${q}%`) as { subject_id: string }[];
190
+ for (const r of factRows) entityIds.add(r.subject_id);
191
+
192
+ // 3. Search relationships by type
193
+ const relRows = db.prepare(
194
+ 'SELECT from_id, to_id FROM relationships WHERE type LIKE ? LIMIT 200'
195
+ ).all(`%${q}%`) as { from_id: string; to_id: string }[];
196
+ for (const r of relRows) {
197
+ entityIds.add(r.from_id);
198
+ entityIds.add(r.to_id);
199
+ }
200
+ } else {
201
+ // No query — return all entities
202
+ const allEntities = findEntities(type ? { type } : {});
203
+ for (const e of allEntities) entityIds.add(e.id);
204
+ }
205
+
206
+ // Filter by type if specified
207
+ const results: Array<{
208
+ entity: ReturnType<typeof getEntity>;
209
+ facts: ReturnType<typeof findFacts>;
210
+ relationships: Array<{ type: string; target: string; direction: 'from' | 'to' }>;
211
+ }> = [];
212
+
213
+ for (const id of entityIds) {
214
+ if (results.length >= limit) break;
215
+ const entity = getEntity(id);
216
+ if (!entity) continue;
217
+ if (type && entity.type !== type) continue;
218
+
219
+ const facts = findFacts({ subject_id: id });
220
+ const rels = getEntityRelationships(id);
221
+ const relationships = rels.map(r => ({
222
+ type: r.type,
223
+ target: r.from_id === id ? r.to_entity.name : r.from_entity.name,
224
+ direction: (r.from_id === id ? 'from' : 'to') as 'from' | 'to',
225
+ }));
226
+
227
+ results.push({ entity, facts, relationships });
228
+ }
229
+
230
+ // Sort by updated_at desc
231
+ results.sort((a, b) => (b.entity!.updated_at) - (a.entity!.updated_at));
232
+
233
+ return json(results);
234
+ },
235
+ },
236
+
237
+ // --- Vault: Commitments ---
238
+ '/api/vault/commitments': {
239
+ GET: (req: Request) => {
240
+ const params = getSearchParams(req);
241
+ const status = params.get('status') as CommitmentStatus | null;
242
+ const priority = params.get('priority') as CommitmentPriority | null;
243
+ const assignedTo = params.get('assigned_to');
244
+ const overdue = params.get('overdue');
245
+ const upcoming = params.get('upcoming');
246
+
247
+ if (upcoming) {
248
+ return json(getUpcoming(parseInt(upcoming) || 10));
249
+ }
250
+
251
+ const query: {
252
+ status?: CommitmentStatus;
253
+ priority?: CommitmentPriority;
254
+ assigned_to?: string;
255
+ overdue?: boolean;
256
+ } = {};
257
+ if (status) query.status = status;
258
+ if (priority) query.priority = priority;
259
+ if (assignedTo) query.assigned_to = assignedTo;
260
+ if (overdue === 'true') query.overdue = true;
261
+ return json(findCommitments(query));
262
+ },
263
+ POST: async (req: Request) => {
264
+ try {
265
+ const body = await req.json() as {
266
+ what: string;
267
+ when_due?: number;
268
+ context?: string;
269
+ priority?: CommitmentPriority;
270
+ assigned_to?: string;
271
+ };
272
+ if (!body.what) return error('Missing "what" field');
273
+ const commitment = createCommitment(body.what, {
274
+ when_due: body.when_due,
275
+ context: body.context,
276
+ priority: body.priority,
277
+ assigned_to: body.assigned_to,
278
+ });
279
+ ctx.wsService?.broadcastTaskUpdate(commitment, 'created');
280
+ return json(commitment, 201);
281
+ } catch (err) {
282
+ return error('Invalid request body');
283
+ }
284
+ },
285
+ },
286
+
287
+ '/api/vault/commitments/reorder': {
288
+ POST: async (req: Request) => {
289
+ try {
290
+ const body = await req.json() as { items: { id: string; sort_order: number }[] };
291
+ if (!body.items || !Array.isArray(body.items)) return error('Missing "items" array');
292
+ reorderCommitments(body.items);
293
+ return json({ ok: true });
294
+ } catch (err) {
295
+ return error('Invalid request body');
296
+ }
297
+ },
298
+ },
299
+
300
+ '/api/vault/commitments/:id': {
301
+ GET: (req: Request & { params: { id: string } }) => {
302
+ const commitment = getCommitment(req.params.id);
303
+ if (!commitment) return error('Commitment not found', 404);
304
+ return json(commitment);
305
+ },
306
+ PATCH: async (req: Request & { params: { id: string } }) => {
307
+ try {
308
+ const body = await req.json() as { status?: CommitmentStatus; result?: string };
309
+ const id = req.params.id;
310
+
311
+ if (!body.status) return error('Missing "status" field');
312
+
313
+ const validStatuses: CommitmentStatus[] = ['pending', 'active', 'completed', 'failed', 'escalated'];
314
+ if (!validStatuses.includes(body.status)) {
315
+ return error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
316
+ }
317
+
318
+ const updated = updateCommitmentStatus(id, body.status, body.result);
319
+ if (!updated) return error('Commitment not found', 404);
320
+ ctx.wsService?.broadcastTaskUpdate(updated, 'updated');
321
+ return json(updated);
322
+ } catch (err) {
323
+ return error('Invalid request body');
324
+ }
325
+ },
326
+ },
327
+
328
+ // --- Vault: Conversations ---
329
+ '/api/vault/conversations': {
330
+ GET: (req: Request) => {
331
+ const params = getSearchParams(req);
332
+ const channel = params.get('channel');
333
+ const limit = Math.min(parseInt(params.get('limit') ?? '20') || 20, 100);
334
+
335
+ const db = getDb();
336
+ let rows;
337
+ if (channel && channel !== 'all') {
338
+ rows = db.prepare(
339
+ 'SELECT * FROM conversations WHERE channel = ? ORDER BY last_message_at DESC LIMIT ?'
340
+ ).all(channel, limit);
341
+ } else {
342
+ rows = db.prepare(
343
+ 'SELECT * FROM conversations ORDER BY last_message_at DESC LIMIT ?'
344
+ ).all(limit);
345
+ }
346
+ return json(rows);
347
+ },
348
+ },
349
+
350
+ '/api/vault/conversations/active': {
351
+ GET: (req: Request) => {
352
+ const params = getSearchParams(req);
353
+ const channel = params.get('channel') ?? 'websocket';
354
+
355
+ if (channel === 'all') {
356
+ // Return the most recent conversation per channel
357
+ const channels = ['websocket', 'telegram', 'discord'];
358
+ const results: Record<string, unknown> = {};
359
+ for (const ch of channels) {
360
+ const result = getRecentConversation(ch);
361
+ if (result) results[ch] = result;
362
+ }
363
+ return json(results);
364
+ }
365
+
366
+ const result = getRecentConversation(channel);
367
+ if (!result) return json({ conversation: null, messages: [] });
368
+ return json(result);
369
+ },
370
+ },
371
+
372
+ '/api/vault/conversations/:id/messages': {
373
+ GET: (req: Request & { params: { id: string } }) => {
374
+ const params = getSearchParams(req);
375
+ const limit = parseInt(params.get('limit') ?? '100') || 100;
376
+ const messages = getMessages(req.params.id, { limit });
377
+ return json(messages);
378
+ },
379
+ },
380
+
381
+ // --- Vault: Observations ---
382
+ '/api/vault/observations': {
383
+ GET: (req: Request) => {
384
+ const params = getSearchParams(req);
385
+ const type = params.get('type') as ObservationType | undefined;
386
+ const limit = parseInt(params.get('limit') ?? '50') || 50;
387
+ return json(getRecentObservations(type, limit));
388
+ },
389
+ },
390
+
391
+ // --- Calendar (unified view of scheduled commitments + content) ---
392
+ '/api/calendar': {
393
+ GET: (req: Request) => {
394
+ const params = getSearchParams(req);
395
+ const rangeStart = parseInt(params.get('range_start') ?? '0');
396
+ const rangeEnd = parseInt(params.get('range_end') ?? '0');
397
+
398
+ if (!rangeStart || !rangeEnd) {
399
+ return error('Missing range_start and/or range_end (Unix ms timestamps)');
400
+ }
401
+
402
+ const db = getDb();
403
+ const events: Array<{
404
+ id: string;
405
+ type: 'commitment' | 'content';
406
+ title: string;
407
+ timestamp: number;
408
+ status: string;
409
+ priority?: string;
410
+ content_type?: string;
411
+ stage?: string;
412
+ assigned_to?: string;
413
+ has_due_date?: boolean;
414
+ }> = [];
415
+
416
+ // Commitments with when_due in range
417
+ const dueRows = db.prepare(
418
+ 'SELECT * FROM commitments WHERE when_due IS NOT NULL AND when_due >= ? AND when_due < ?'
419
+ ).all(rangeStart, rangeEnd) as any[];
420
+
421
+ for (const row of dueRows) {
422
+ events.push({
423
+ id: row.id,
424
+ type: 'commitment',
425
+ title: row.what,
426
+ timestamp: row.when_due,
427
+ status: row.status,
428
+ priority: row.priority,
429
+ assigned_to: row.assigned_to ?? undefined,
430
+ has_due_date: true,
431
+ });
432
+ }
433
+
434
+ // Commitments WITHOUT due date — show on created_at date (pending/active only)
435
+ const noDueRows = db.prepare(
436
+ "SELECT * FROM commitments WHERE when_due IS NULL AND status IN ('pending', 'active') AND created_at >= ? AND created_at < ?"
437
+ ).all(rangeStart, rangeEnd) as any[];
438
+
439
+ for (const row of noDueRows) {
440
+ events.push({
441
+ id: row.id,
442
+ type: 'commitment',
443
+ title: row.what,
444
+ timestamp: row.created_at,
445
+ status: row.status,
446
+ priority: row.priority,
447
+ assigned_to: row.assigned_to ?? undefined,
448
+ has_due_date: false,
449
+ });
450
+ }
451
+
452
+ // Content items with scheduled_at in range
453
+ const contentRows = db.prepare(
454
+ 'SELECT * FROM content_items WHERE scheduled_at IS NOT NULL AND scheduled_at >= ? AND scheduled_at < ?'
455
+ ).all(rangeStart, rangeEnd) as any[];
456
+
457
+ for (const row of contentRows) {
458
+ events.push({
459
+ id: row.id,
460
+ type: 'content',
461
+ title: row.title,
462
+ timestamp: row.scheduled_at,
463
+ status: row.stage,
464
+ content_type: row.content_type,
465
+ stage: row.stage,
466
+ });
467
+ }
468
+
469
+ // Sort by timestamp
470
+ events.sort((a, b) => a.timestamp - b.timestamp);
471
+
472
+ return json(events);
473
+ },
474
+ },
475
+
476
+ // --- Agents ---
477
+ '/api/agents': {
478
+ GET: () => {
479
+ const orchestrator = ctx.agentService.getOrchestrator();
480
+ const agents = orchestrator.getAllAgents().map((a) => a.toJSON());
481
+ return json(agents);
482
+ },
483
+ },
484
+
485
+ '/api/agents/tree': {
486
+ GET: () => {
487
+ const orchestrator = ctx.agentService.getOrchestrator();
488
+ const all = orchestrator.getAllAgents().map((a) => a.toJSON());
489
+ // Build tree structure
490
+ const primary = all.find((a) => !a.parent_id);
491
+ const children = all.filter((a) => a.parent_id);
492
+ return json({
493
+ primary: primary ?? null,
494
+ children,
495
+ });
496
+ },
497
+ },
498
+
499
+ '/api/agents/tasks': {
500
+ GET: () => {
501
+ const tm = ctx.agentService.getTaskManager();
502
+ if (!tm) return json({ tasks: [] });
503
+ const tasks = tm.listTasks().map(t => ({
504
+ id: t.id,
505
+ agent_id: t.agentId,
506
+ agent_name: t.agentName,
507
+ specialist: t.specialistId,
508
+ task: t.task,
509
+ status: t.status,
510
+ started_at: t.startedAt,
511
+ completed_at: t.completedAt,
512
+ success: t.result?.success ?? null,
513
+ elapsed_ms: (t.completedAt ?? Date.now()) - t.startedAt,
514
+ }));
515
+ return json({ tasks });
516
+ },
517
+ },
518
+
519
+ // --- Personality ---
520
+ '/api/personality': {
521
+ GET: () => json(getPersonality()),
522
+ },
523
+
524
+ // --- Config (sanitized — no API keys) ---
525
+ '/api/config': {
526
+ GET: () => {
527
+ const config = ctx.config;
528
+ return json({
529
+ daemon: config.daemon,
530
+ llm: {
531
+ primary: config.llm.primary,
532
+ fallback: config.llm.fallback,
533
+ anthropic: config.llm.anthropic ? { model: config.llm.anthropic.model } : null,
534
+ openai: config.llm.openai ? { model: config.llm.openai.model } : null,
535
+ ollama: config.llm.ollama ?? null,
536
+ },
537
+ personality: config.personality,
538
+ authority: config.authority,
539
+ heartbeat: config.heartbeat,
540
+ active_role: config.active_role,
541
+ });
542
+ },
543
+ },
544
+
545
+ // --- LLM Configuration (DB + encrypted keychain) ---
546
+ '/api/config/llm': {
547
+ GET: async () => {
548
+ const { getLLMSettings } = await import('./llm-settings.ts');
549
+ return json(getLLMSettings(ctx.config));
550
+ },
551
+ POST: async (req: Request) => {
552
+ try {
553
+ const body = await req.json() as Record<string, unknown>;
554
+ const { saveLLMSettings, hotReloadLLMProviders } = await import('./llm-settings.ts');
555
+
556
+ saveLLMSettings(ctx.config, body as any);
557
+
558
+ // Hot-reload providers on the shared LLMManager
559
+ const llmManager = ctx.agentService.getLLMManager();
560
+ hotReloadLLMProviders(ctx.config, llmManager);
561
+
562
+ return json({ ok: true, message: 'LLM configuration saved and applied.' });
563
+ } catch (err) {
564
+ const msg = err instanceof Error ? err.message : String(err);
565
+ return error(`Failed to save LLM config: ${msg}`);
566
+ }
567
+ },
568
+ },
569
+
570
+ '/api/config/llm/test': {
571
+ POST: async (req: Request) => {
572
+ try {
573
+ const body = await req.json() as { provider: string; api_key?: string; model?: string; base_url?: string };
574
+ const { testLLMProvider } = await import('./llm-settings.ts');
575
+ const result = await testLLMProvider(body, ctx.config);
576
+ return json(result);
577
+ } catch (err) {
578
+ return error('Invalid request body');
579
+ }
580
+ },
581
+ },
582
+
583
+ // --- Roles ---
584
+ '/api/roles': {
585
+ GET: () => {
586
+ const orchestrator = ctx.agentService.getOrchestrator();
587
+ const primary = orchestrator.getPrimary();
588
+ return json({
589
+ active_role: primary?.agent.role.name ?? ctx.config.active_role,
590
+ // Note: specialist list is injected via prompt-builder, not directly accessible here
591
+ // We'll return what we can from the agent's role
592
+ role: primary?.agent.role ? {
593
+ id: primary.agent.role.id,
594
+ name: primary.agent.role.name,
595
+ authority_level: primary.agent.role.authority_level,
596
+ tools: primary.agent.role.tools,
597
+ sub_roles: primary.agent.role.sub_roles,
598
+ } : null,
599
+ });
600
+ },
601
+ },
602
+
603
+ // --- Content Pipeline ---
604
+ '/api/content': {
605
+ GET: (req: Request) => {
606
+ const params = getSearchParams(req);
607
+ const stage = params.get('stage') as ContentStage | null;
608
+ const content_type = params.get('type') as ContentType | null;
609
+ const tag = params.get('tag');
610
+ const query: { stage?: ContentStage; content_type?: ContentType; tag?: string } = {};
611
+ if (stage) query.stage = stage;
612
+ if (content_type) query.content_type = content_type;
613
+ if (tag) query.tag = tag;
614
+ return json(findContent(query));
615
+ },
616
+ POST: async (req: Request) => {
617
+ try {
618
+ const body = await req.json() as {
619
+ title: string;
620
+ body?: string;
621
+ content_type?: ContentType;
622
+ stage?: ContentStage;
623
+ tags?: string[];
624
+ created_by?: string;
625
+ };
626
+ if (!body.title) return error('Missing "title" field');
627
+ const item = createContent(body.title, {
628
+ body: body.body,
629
+ content_type: body.content_type,
630
+ stage: body.stage,
631
+ tags: body.tags,
632
+ created_by: body.created_by,
633
+ });
634
+ ctx.wsService?.broadcastContentUpdate(item, 'created');
635
+ return json(item, 201);
636
+ } catch (err) {
637
+ return error('Invalid request body');
638
+ }
639
+ },
640
+ },
641
+
642
+ '/api/content/:id': {
643
+ GET: (req: Request & { params: { id: string } }) => {
644
+ const item = getContent(req.params.id);
645
+ if (!item) return error('Content not found', 404);
646
+ return json(item);
647
+ },
648
+ PATCH: async (req: Request & { params: { id: string } }) => {
649
+ try {
650
+ const body = await req.json() as {
651
+ title?: string;
652
+ body?: string;
653
+ content_type?: ContentType;
654
+ stage?: ContentStage;
655
+ tags?: string[];
656
+ scheduled_at?: number | null;
657
+ published_at?: number | null;
658
+ published_url?: string | null;
659
+ sort_order?: number;
660
+ };
661
+ const updated = updateContent(req.params.id, body);
662
+ if (!updated) return error('Content not found', 404);
663
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
664
+ return json(updated);
665
+ } catch (err) {
666
+ return error('Invalid request body');
667
+ }
668
+ },
669
+ DELETE: (req: Request & { params: { id: string } }) => {
670
+ const existing = getContent(req.params.id);
671
+ if (!existing) return error('Content not found', 404);
672
+ deleteContent(req.params.id);
673
+ ctx.wsService?.broadcastContentUpdate(existing, 'deleted');
674
+ return json({ ok: true });
675
+ },
676
+ },
677
+
678
+ '/api/content/:id/advance': {
679
+ POST: (req: Request & { params: { id: string } }) => {
680
+ const updated = advanceStage(req.params.id);
681
+ if (!updated) return error('Cannot advance (not found or already at last stage)', 400);
682
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
683
+ return json(updated);
684
+ },
685
+ },
686
+
687
+ '/api/content/:id/regress': {
688
+ POST: (req: Request & { params: { id: string } }) => {
689
+ const updated = regressStage(req.params.id);
690
+ if (!updated) return error('Cannot regress (not found or already at first stage)', 400);
691
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
692
+ return json(updated);
693
+ },
694
+ },
695
+
696
+ '/api/content/:id/notes': {
697
+ GET: (req: Request & { params: { id: string } }) => {
698
+ const params = getSearchParams(req);
699
+ const stage = params.get('stage') as ContentStage | null;
700
+ return json(getStageNotes(req.params.id, stage ?? undefined));
701
+ },
702
+ POST: async (req: Request & { params: { id: string } }) => {
703
+ try {
704
+ const body = await req.json() as {
705
+ stage: ContentStage;
706
+ note: string;
707
+ author?: string;
708
+ };
709
+ if (!body.stage || !body.note) return error('Missing "stage" or "note" field');
710
+ const note = addStageNote(req.params.id, body.stage, body.note, body.author);
711
+ // Broadcast content update so UI refreshes
712
+ const item = getContent(req.params.id);
713
+ if (item) ctx.wsService?.broadcastContentUpdate(item, 'updated');
714
+ return json(note, 201);
715
+ } catch (err) {
716
+ return error('Invalid request body');
717
+ }
718
+ },
719
+ },
720
+
721
+ '/api/content/:id/attachments': {
722
+ GET: (req: Request & { params: { id: string } }) => {
723
+ return json(getAttachments(req.params.id));
724
+ },
725
+ POST: async (req: Request & { params: { id: string } }) => {
726
+ try {
727
+ const contentId = req.params.id;
728
+ const item = getContent(contentId);
729
+ if (!item) return error('Content not found', 404);
730
+
731
+ const formData = await req.formData();
732
+ const file = formData.get('file') as File | null;
733
+ if (!file) return error('Missing "file" in form data');
734
+
735
+ const label = (formData.get('label') as string) || null;
736
+
737
+ // Save file to ~/.jarvis/content/<id>/
738
+ const contentDir = path.join(os.homedir(), '.jarvis', 'content', contentId);
739
+ if (!existsSync(contentDir)) {
740
+ mkdirSync(contentDir, { recursive: true });
741
+ }
742
+
743
+ const diskPath = path.join(contentDir, file.name);
744
+ await Bun.write(diskPath, file);
745
+
746
+ const attachment = addAttachment(
747
+ contentId,
748
+ file.name,
749
+ diskPath,
750
+ file.type || 'application/octet-stream',
751
+ file.size,
752
+ label ?? undefined,
753
+ );
754
+
755
+ ctx.wsService?.broadcastContentUpdate(item, 'updated');
756
+ return json(attachment, 201);
757
+ } catch (err) {
758
+ return error('File upload failed');
759
+ }
760
+ },
761
+ },
762
+
763
+ '/api/content/:id/attachments/:aid': {
764
+ DELETE: (req: Request & { params: { id: string; aid: string } }) => {
765
+ const deleted = deleteAttachment(req.params.aid);
766
+ if (!deleted) return error('Attachment not found', 404);
767
+ const item = getContent(req.params.id);
768
+ if (item) ctx.wsService?.broadcastContentUpdate(item, 'updated');
769
+ return json({ ok: true });
770
+ },
771
+ },
772
+
773
+ '/api/content/files/:contentId/:filename': {
774
+ GET: (req: Request & { params: { contentId: string; filename: string } }) => {
775
+ const filePath = path.join(
776
+ os.homedir(), '.jarvis', 'content',
777
+ req.params.contentId, req.params.filename
778
+ );
779
+ const file = Bun.file(filePath);
780
+ return new Response(file, {
781
+ headers: { ...CORS },
782
+ });
783
+ },
784
+ },
785
+
786
+ // --- Google OAuth Callback ---
787
+ '/api/auth/google/callback': {
788
+ GET: async (req: Request) => {
789
+ const params = getSearchParams(req);
790
+ const code = params.get('code');
791
+ const authError = params.get('error');
792
+
793
+ if (authError) {
794
+ return new Response(
795
+ `<html><body><h1>Authorization Denied</h1><p>${authError}</p><p>You can close this tab.</p></body></html>`,
796
+ { headers: { ...CORS, 'Content-Type': 'text/html' } }
797
+ );
798
+ }
799
+
800
+ if (!code) {
801
+ return error('Missing authorization code', 400);
802
+ }
803
+
804
+ // Try to exchange the code using GoogleAuth from context
805
+ const googleConfig = ctx.config.google;
806
+ if (!googleConfig?.client_id || !googleConfig?.client_secret) {
807
+ return error('Google OAuth not configured in config.yaml', 500);
808
+ }
809
+
810
+ try {
811
+ // Lazy import to avoid circular deps
812
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
813
+ const auth = new GoogleAuth(googleConfig.client_id, googleConfig.client_secret);
814
+ await auth.exchangeCode(code);
815
+
816
+ return new Response(
817
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px">
818
+ <h1>JARVIS Google Authorization Complete!</h1>
819
+ <p>Tokens saved. This window will close automatically.</p>
820
+ <script>
821
+ if (window.opener) { window.opener.postMessage('google-auth-complete', '*'); }
822
+ setTimeout(function() { window.close(); }, 2000);
823
+ </script>
824
+ </body></html>`,
825
+ { headers: { ...CORS, 'Content-Type': 'text/html' } }
826
+ );
827
+ } catch (err) {
828
+ const msg = err instanceof Error ? err.message : String(err);
829
+ return new Response(
830
+ `<html><body><h1>Token Exchange Failed</h1><pre>${msg}</pre></body></html>`,
831
+ { headers: { ...CORS, 'Content-Type': 'text/html' }, status: 500 }
832
+ );
833
+ }
834
+ },
835
+ },
836
+
837
+ // --- Google Auth Management ---
838
+ '/api/auth/google/status': {
839
+ GET: async () => {
840
+ const googleConfig = ctx.config.google;
841
+ const hasCredentials = !!(googleConfig?.client_id && googleConfig?.client_secret);
842
+
843
+ if (!hasCredentials) {
844
+ return json({ status: 'not_configured', has_credentials: false, is_authenticated: false, scopes: [], token_expiry: null });
845
+ }
846
+
847
+ try {
848
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
849
+ const auth = new GoogleAuth(googleConfig!.client_id, googleConfig!.client_secret);
850
+ const authenticated = auth.isAuthenticated();
851
+ const tokens = auth.loadTokens();
852
+
853
+ return json({
854
+ status: authenticated ? 'connected' : 'credentials_saved',
855
+ has_credentials: true,
856
+ is_authenticated: authenticated,
857
+ scopes: ['gmail.readonly', 'calendar.readonly'],
858
+ token_expiry: tokens?.expiry_date ?? null,
859
+ });
860
+ } catch {
861
+ return json({ status: 'credentials_saved', has_credentials: true, is_authenticated: false, scopes: [], token_expiry: null });
862
+ }
863
+ },
864
+ },
865
+
866
+ '/api/config/google': {
867
+ POST: async (req: Request) => {
868
+ try {
869
+ const body = await req.json() as { client_id: string; client_secret: string };
870
+ if (!body.client_id || !body.client_secret) {
871
+ return error('Missing client_id or client_secret');
872
+ }
873
+
874
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
875
+ const freshConfig = await loadConfig();
876
+ freshConfig.google = { client_id: body.client_id, client_secret: body.client_secret };
877
+ await saveConfig(freshConfig);
878
+
879
+ // Update in-memory config so callback route sees credentials immediately
880
+ ctx.config.google = freshConfig.google;
881
+
882
+ return json({ ok: true });
883
+ } catch (err) {
884
+ const msg = err instanceof Error ? err.message : String(err);
885
+ return error(`Failed to save Google config: ${msg}`, 500);
886
+ }
887
+ },
888
+ },
889
+
890
+ '/api/auth/google/init': {
891
+ POST: async () => {
892
+ const googleConfig = ctx.config.google;
893
+ if (!googleConfig?.client_id || !googleConfig?.client_secret) {
894
+ return error('Google credentials not configured. Save client_id and client_secret first.', 400);
895
+ }
896
+
897
+ try {
898
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
899
+ const auth = new GoogleAuth(googleConfig.client_id, googleConfig.client_secret);
900
+ const scopes = [
901
+ 'https://www.googleapis.com/auth/gmail.readonly',
902
+ 'https://www.googleapis.com/auth/calendar.readonly',
903
+ ];
904
+ const authUrl = auth.getAuthUrl(scopes);
905
+ return json({ auth_url: authUrl });
906
+ } catch (err) {
907
+ const msg = err instanceof Error ? err.message : String(err);
908
+ return error(`Failed to generate auth URL: ${msg}`, 500);
909
+ }
910
+ },
911
+ },
912
+
913
+ '/api/auth/google/disconnect': {
914
+ POST: async () => {
915
+ try {
916
+ const tokensPath = path.join(os.homedir(), '.jarvis', 'google-tokens.json');
917
+ if (existsSync(tokensPath)) {
918
+ const { unlinkSync } = await import('node:fs');
919
+ unlinkSync(tokensPath);
920
+ }
921
+ return json({ ok: true, message: 'Disconnected. Restart JARVIS to deactivate observers.' });
922
+ } catch (err) {
923
+ const msg = err instanceof Error ? err.message : String(err);
924
+ return error(`Failed to disconnect: ${msg}`, 500);
925
+ }
926
+ },
927
+ },
928
+
929
+ // --- Channels ---
930
+ '/api/channels/status': {
931
+ GET: () => {
932
+ if (!ctx.channelService) return json({ channels: {}, stt: null });
933
+ return json({
934
+ channels: ctx.channelService.getChannelStatus(),
935
+ stt: ctx.config.stt?.provider ?? null,
936
+ });
937
+ },
938
+ },
939
+
940
+ '/api/config/channels': {
941
+ GET: () => {
942
+ const cfg = ctx.config.channels;
943
+ return json({
944
+ telegram: cfg?.telegram ? {
945
+ enabled: cfg.telegram.enabled,
946
+ has_token: !!cfg.telegram.bot_token,
947
+ allowed_users: cfg.telegram.allowed_users,
948
+ } : { enabled: false, has_token: false, allowed_users: [] },
949
+ discord: cfg?.discord ? {
950
+ enabled: cfg.discord.enabled,
951
+ has_token: !!cfg.discord.bot_token,
952
+ allowed_users: cfg.discord.allowed_users,
953
+ guild_id: cfg.discord.guild_id ?? null,
954
+ } : { enabled: false, has_token: false, allowed_users: [], guild_id: null },
955
+ });
956
+ },
957
+ POST: async (req: Request) => {
958
+ try {
959
+ const body = await req.json() as Record<string, unknown>;
960
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
961
+ const freshConfig = await loadConfig();
962
+
963
+ if (!freshConfig.channels) freshConfig.channels = {};
964
+
965
+ if (body.telegram && typeof body.telegram === 'object') {
966
+ freshConfig.channels.telegram = {
967
+ ...freshConfig.channels.telegram,
968
+ ...(body.telegram as Record<string, unknown>),
969
+ } as any;
970
+ }
971
+ if (body.discord && typeof body.discord === 'object') {
972
+ freshConfig.channels.discord = {
973
+ ...freshConfig.channels.discord,
974
+ ...(body.discord as Record<string, unknown>),
975
+ } as any;
976
+ }
977
+
978
+ await saveConfig(freshConfig);
979
+ ctx.config.channels = freshConfig.channels;
980
+
981
+ return json({ ok: true, message: 'Channel config saved. Restart JARVIS to apply changes.' });
982
+ } catch (err) {
983
+ return error('Invalid request body');
984
+ }
985
+ },
986
+ },
987
+
988
+ '/api/config/stt': {
989
+ GET: () => {
990
+ const stt = ctx.config.stt;
991
+ return json({
992
+ provider: stt?.provider ?? 'openai',
993
+ has_openai_key: !!stt?.openai?.api_key,
994
+ has_groq_key: !!stt?.groq?.api_key,
995
+ local_endpoint: stt?.local?.endpoint ?? null,
996
+ });
997
+ },
998
+ POST: async (req: Request) => {
999
+ try {
1000
+ const body = await req.json() as Record<string, unknown>;
1001
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1002
+ const freshConfig = await loadConfig();
1003
+ freshConfig.stt = { ...freshConfig.stt, ...body } as any;
1004
+ await saveConfig(freshConfig);
1005
+ ctx.config.stt = freshConfig.stt;
1006
+ return json({ ok: true, message: 'STT config saved. Restart JARVIS to apply changes.' });
1007
+ } catch (err) {
1008
+ return error('Invalid request body');
1009
+ }
1010
+ },
1011
+ },
1012
+
1013
+ '/api/config/tts': {
1014
+ GET: () => {
1015
+ const tts = ctx.config.tts;
1016
+ return json({
1017
+ enabled: tts?.enabled ?? false,
1018
+ provider: tts?.provider ?? 'edge',
1019
+ voice: tts?.voice ?? 'en-US-AriaNeural',
1020
+ rate: tts?.rate ?? '+0%',
1021
+ volume: tts?.volume ?? '+0%',
1022
+ elevenlabs: tts?.elevenlabs ? {
1023
+ has_api_key: !!tts.elevenlabs.api_key,
1024
+ voice_id: tts.elevenlabs.voice_id ?? null,
1025
+ model: tts.elevenlabs.model ?? 'eleven_flash_v2_5',
1026
+ stability: tts.elevenlabs.stability ?? 0.5,
1027
+ similarity_boost: tts.elevenlabs.similarity_boost ?? 0.75,
1028
+ } : null,
1029
+ });
1030
+ },
1031
+ POST: async (req: Request) => {
1032
+ try {
1033
+ const body = await req.json() as Record<string, unknown>;
1034
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1035
+ const freshConfig = await loadConfig();
1036
+
1037
+ // Deep-merge elevenlabs sub-object to preserve API key across saves
1038
+ const incomingEl = body.elevenlabs as Record<string, unknown> | undefined;
1039
+ const existingEl = freshConfig.tts?.elevenlabs;
1040
+ delete body.elevenlabs;
1041
+
1042
+ freshConfig.tts = { ...freshConfig.tts, ...body } as any;
1043
+
1044
+ if (incomingEl) {
1045
+ freshConfig.tts!.elevenlabs = {
1046
+ ...existingEl,
1047
+ ...incomingEl,
1048
+ // Keep existing API key if new one not provided
1049
+ api_key: (incomingEl.api_key as string) || existingEl?.api_key || '',
1050
+ } as any;
1051
+ }
1052
+
1053
+ await saveConfig(freshConfig);
1054
+ ctx.config.tts = freshConfig.tts;
1055
+
1056
+ // Hot-reload TTS provider if wsService available
1057
+ if (ctx.wsService && freshConfig.tts) {
1058
+ const { createTTSProvider } = await import('../comms/voice.ts');
1059
+ const provider = createTTSProvider(freshConfig.tts);
1060
+ if (provider) {
1061
+ ctx.wsService.setTTSProvider(provider);
1062
+ }
1063
+ }
1064
+
1065
+ return json({ ok: true, message: 'TTS config saved.' });
1066
+ } catch (err) {
1067
+ return error('Invalid request body');
1068
+ }
1069
+ },
1070
+ },
1071
+
1072
+ // --- TTS Voices ---
1073
+ '/api/tts/voices': {
1074
+ GET: async (req: Request) => {
1075
+ const params = getSearchParams(req);
1076
+ const provider = params.get('provider') ?? 'edge';
1077
+
1078
+ if (provider === 'elevenlabs') {
1079
+ const apiKey = ctx.config.tts?.elevenlabs?.api_key;
1080
+ if (!apiKey) return error('ElevenLabs API key not configured', 400);
1081
+
1082
+ try {
1083
+ const { listElevenLabsVoices } = await import('../comms/voice.ts');
1084
+ const voices = await listElevenLabsVoices(apiKey);
1085
+ return json(voices);
1086
+ } catch (err) {
1087
+ const msg = err instanceof Error ? err.message : String(err);
1088
+ return error(`Failed to fetch ElevenLabs voices: ${msg}`, 500);
1089
+ }
1090
+ }
1091
+
1092
+ // Edge TTS: return hardcoded voice list
1093
+ return json([
1094
+ { voice_id: 'en-US-AriaNeural', name: 'Aria (US Female)', category: 'neural' },
1095
+ { voice_id: 'en-US-GuyNeural', name: 'Guy (US Male)', category: 'neural' },
1096
+ { voice_id: 'en-GB-SoniaNeural', name: 'Sonia (UK Female)', category: 'neural' },
1097
+ { voice_id: 'en-AU-NatashaNeural', name: 'Natasha (AU Female)', category: 'neural' },
1098
+ { voice_id: 'en-US-JennyNeural', name: 'Jenny (US Female)', category: 'neural' },
1099
+ { voice_id: 'en-US-DavisNeural', name: 'Davis (US Male)', category: 'neural' },
1100
+ ]);
1101
+ },
1102
+ },
1103
+
1104
+ // --- Authority & Autonomy ---
1105
+ '/api/authority/status': {
1106
+ GET: () => {
1107
+ const engine = ctx.authorityEngine;
1108
+ const emergency = ctx.emergencyController;
1109
+ const approvals = ctx.approvalManager;
1110
+ if (!engine || !emergency) return json({ enabled: false });
1111
+
1112
+ return json({
1113
+ enabled: true,
1114
+ emergency_state: emergency.getState(),
1115
+ pending_approvals: approvals?.getPending().length ?? 0,
1116
+ config: engine.getConfig(),
1117
+ });
1118
+ },
1119
+ },
1120
+
1121
+ '/api/authority/approvals': {
1122
+ GET: (req: Request) => {
1123
+ if (!ctx.approvalManager) return json([]);
1124
+ const params = getSearchParams(req);
1125
+ const status = params.get('status');
1126
+ if (status === 'pending') {
1127
+ return json(ctx.approvalManager.getPending());
1128
+ }
1129
+ return json(ctx.approvalManager.getHistory({
1130
+ limit: parseInt(params.get('limit') ?? '50') || 50,
1131
+ action: (params.get('action') as ActionCategory) || undefined,
1132
+ agentId: params.get('agent_id') || undefined,
1133
+ status: (params.get('status') as any) || undefined,
1134
+ }));
1135
+ },
1136
+ },
1137
+
1138
+ '/api/authority/approvals/:id/approve': {
1139
+ POST: async (req: Request & { params: { id: string } }) => {
1140
+ if (!ctx.approvalManager || !ctx.deferredExecutor) {
1141
+ return error('Authority system not configured', 500);
1142
+ }
1143
+ const requestId = req.params.id;
1144
+ const approved = ctx.approvalManager.approve(requestId, 'dashboard');
1145
+ if (!approved) return error('Request not found or already decided', 404);
1146
+
1147
+ // Execute the approved tool
1148
+ const result = await ctx.deferredExecutor.executeApproved(requestId);
1149
+
1150
+ // Broadcast the update
1151
+ const updated = ctx.approvalManager.getRequest(requestId);
1152
+ if (updated) ctx.wsService?.broadcastApprovalUpdate(updated);
1153
+
1154
+ return json({ ok: true, result: result.slice(0, 500) });
1155
+ },
1156
+ },
1157
+
1158
+ '/api/authority/approvals/:id/deny': {
1159
+ POST: async (req: Request & { params: { id: string } }) => {
1160
+ if (!ctx.approvalManager || !ctx.deferredExecutor) {
1161
+ return error('Authority system not configured', 500);
1162
+ }
1163
+ const requestId = req.params.id;
1164
+ const denied = ctx.approvalManager.deny(requestId, 'dashboard');
1165
+ if (!denied) return error('Request not found or already decided', 404);
1166
+
1167
+ // Record denial for learning
1168
+ ctx.deferredExecutor.recordDenial(denied);
1169
+
1170
+ // Broadcast the update
1171
+ ctx.wsService?.broadcastApprovalUpdate(denied);
1172
+
1173
+ return json({ ok: true });
1174
+ },
1175
+ },
1176
+
1177
+ '/api/authority/audit': {
1178
+ GET: (req: Request) => {
1179
+ if (!ctx.auditTrail) return json([]);
1180
+ const params = getSearchParams(req);
1181
+ return json(ctx.auditTrail.query({
1182
+ agentId: params.get('agent_id') || undefined,
1183
+ action: (params.get('action') as ActionCategory) || undefined,
1184
+ tool: params.get('tool') || undefined,
1185
+ decision: (params.get('decision') as AuthorityDecisionType) || undefined,
1186
+ since: params.get('since') ? parseInt(params.get('since')!) : undefined,
1187
+ limit: parseInt(params.get('limit') ?? '100') || 100,
1188
+ }));
1189
+ },
1190
+ },
1191
+
1192
+ '/api/authority/audit/stats': {
1193
+ GET: (req: Request) => {
1194
+ if (!ctx.auditTrail) return json({ total: 0, allowed: 0, denied: 0, approvalRequired: 0, byCategory: {} });
1195
+ const params = getSearchParams(req);
1196
+ const since = params.get('since') ? parseInt(params.get('since')!) : undefined;
1197
+ return json(ctx.auditTrail.getStats(since));
1198
+ },
1199
+ },
1200
+
1201
+ '/api/authority/emergency/pause': {
1202
+ POST: () => {
1203
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1204
+ ctx.emergencyController.pause();
1205
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1206
+ },
1207
+ },
1208
+
1209
+ '/api/authority/emergency/resume': {
1210
+ POST: () => {
1211
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1212
+ ctx.emergencyController.resume();
1213
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1214
+ },
1215
+ },
1216
+
1217
+ '/api/authority/emergency/kill': {
1218
+ POST: () => {
1219
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1220
+ ctx.emergencyController.kill();
1221
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1222
+ },
1223
+ },
1224
+
1225
+ '/api/authority/emergency/reset': {
1226
+ POST: () => {
1227
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1228
+ ctx.emergencyController.reset();
1229
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1230
+ },
1231
+ },
1232
+
1233
+ '/api/authority/config': {
1234
+ GET: () => {
1235
+ if (!ctx.authorityEngine) return json({});
1236
+ return json(ctx.authorityEngine.getConfig());
1237
+ },
1238
+ POST: async (req: Request) => {
1239
+ if (!ctx.authorityEngine) return error('Authority engine not configured', 500);
1240
+ try {
1241
+ const body = await req.json() as Record<string, unknown>;
1242
+ const currentConfig = ctx.authorityEngine.getConfig();
1243
+
1244
+ // Merge updates into current config
1245
+ if (body.governed_categories) currentConfig.governed_categories = body.governed_categories as ActionCategory[];
1246
+ if (body.default_level !== undefined) currentConfig.default_level = body.default_level as number;
1247
+ if (body.overrides) currentConfig.overrides = body.overrides as any[];
1248
+ if (body.context_rules) currentConfig.context_rules = body.context_rules as any[];
1249
+ if (body.learning) currentConfig.learning = { ...currentConfig.learning, ...body.learning as any };
1250
+
1251
+ ctx.authorityEngine.updateConfig(currentConfig);
1252
+
1253
+ // Persist to config.yaml
1254
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1255
+ const freshConfig = await loadConfig();
1256
+ freshConfig.authority = {
1257
+ ...freshConfig.authority,
1258
+ default_level: currentConfig.default_level,
1259
+ governed_categories: currentConfig.governed_categories,
1260
+ overrides: currentConfig.overrides,
1261
+ context_rules: currentConfig.context_rules,
1262
+ learning: currentConfig.learning,
1263
+ };
1264
+ await saveConfig(freshConfig);
1265
+
1266
+ return json({ ok: true, config: currentConfig });
1267
+ } catch (err) {
1268
+ return error('Invalid request body');
1269
+ }
1270
+ },
1271
+ },
1272
+
1273
+ '/api/authority/learning/suggestions': {
1274
+ GET: () => {
1275
+ if (!ctx.learner) return json([]);
1276
+ return json(ctx.learner.getSuggestions());
1277
+ },
1278
+ },
1279
+
1280
+ '/api/authority/learning/accept': {
1281
+ POST: async (req: Request) => {
1282
+ if (!ctx.learner || !ctx.authorityEngine) {
1283
+ return error('Learning system not configured', 500);
1284
+ }
1285
+ try {
1286
+ const body = await req.json() as { action: ActionCategory; tool_name: string };
1287
+ if (!body.action) return error('Missing "action" field');
1288
+
1289
+ // Add the override to the engine
1290
+ ctx.authorityEngine.addOverride({
1291
+ action: body.action,
1292
+ allowed: true,
1293
+ requires_approval: false,
1294
+ });
1295
+
1296
+ // Mark suggestion as sent
1297
+ ctx.learner.markSuggestionSent(body.action, body.tool_name ?? '');
1298
+
1299
+ // Persist
1300
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1301
+ const freshConfig = await loadConfig();
1302
+ freshConfig.authority = {
1303
+ ...freshConfig.authority,
1304
+ ...ctx.authorityEngine.getConfig(),
1305
+ };
1306
+ await saveConfig(freshConfig);
1307
+
1308
+ return json({ ok: true });
1309
+ } catch (err) {
1310
+ return error('Invalid request body');
1311
+ }
1312
+ },
1313
+ },
1314
+
1315
+ '/api/authority/learning/dismiss': {
1316
+ POST: async (req: Request) => {
1317
+ if (!ctx.learner) return error('Learning system not configured', 500);
1318
+ try {
1319
+ const body = await req.json() as { action: ActionCategory; tool_name: string };
1320
+ if (!body.action) return error('Missing "action" field');
1321
+ ctx.learner.resetPattern(body.action, body.tool_name ?? '');
1322
+ return json({ ok: true });
1323
+ } catch (err) {
1324
+ return error('Invalid request body');
1325
+ }
1326
+ },
1327
+ },
1328
+
1329
+ // --- Awareness (M13) ---
1330
+ '/api/awareness/status': {
1331
+ GET: () => {
1332
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1333
+ return json({
1334
+ status: ctx.awarenessService.status(),
1335
+ enabled: ctx.awarenessService.isEnabled(),
1336
+ liveContext: ctx.awarenessService.getLiveContext(),
1337
+ });
1338
+ },
1339
+ },
1340
+
1341
+ '/api/awareness/context': {
1342
+ GET: () => {
1343
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1344
+ return json(ctx.awarenessService.getLiveContext());
1345
+ },
1346
+ },
1347
+
1348
+ '/api/awareness/captures': {
1349
+ GET: (req: Request) => {
1350
+ const params = getSearchParams(req);
1351
+ const limit = parseInt(params.get('limit') ?? '50', 10);
1352
+ const app = params.get('app') ?? undefined;
1353
+ return json(getRecentCaptures(limit, app));
1354
+ },
1355
+ },
1356
+
1357
+ '/api/awareness/captures/:id': {
1358
+ GET: (req: Request & { params: { id: string } }) => {
1359
+ const capture = getCapture(req.params.id);
1360
+ if (!capture) return error('Capture not found', 404);
1361
+ return json(capture);
1362
+ },
1363
+ },
1364
+
1365
+ '/api/awareness/captures/:id/image': {
1366
+ GET: (req: Request & { params: { id: string } }) => {
1367
+ const capture = getCapture(req.params.id);
1368
+ if (!capture || !capture.image_path) return error('Image not found', 404);
1369
+ try {
1370
+ const imageData = readFileSync(capture.image_path);
1371
+ return new Response(imageData, {
1372
+ headers: { ...CORS, 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
1373
+ });
1374
+ } catch {
1375
+ return error('Image file not found on disk', 404);
1376
+ }
1377
+ },
1378
+ },
1379
+
1380
+ '/api/awareness/captures/:id/thumbnail': {
1381
+ GET: (req: Request & { params: { id: string } }) => {
1382
+ const capture = getCapture(req.params.id);
1383
+ if (!capture) return error('Capture not found', 404);
1384
+ // Prefer thumbnail, fall back to full image
1385
+ if (capture.thumbnail_path) {
1386
+ try {
1387
+ const thumbData = readFileSync(capture.thumbnail_path);
1388
+ return new Response(thumbData, {
1389
+ headers: { ...CORS, 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=3600' },
1390
+ });
1391
+ } catch { /* thumbnail file missing, fall through */ }
1392
+ }
1393
+ if (capture.image_path) {
1394
+ try {
1395
+ const imageData = readFileSync(capture.image_path);
1396
+ return new Response(imageData, {
1397
+ headers: { ...CORS, 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
1398
+ });
1399
+ } catch { /* fall through */ }
1400
+ }
1401
+ return error('Thumbnail not found', 404);
1402
+ },
1403
+ },
1404
+
1405
+ '/api/awareness/sessions': {
1406
+ GET: (req: Request) => {
1407
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1408
+ const params = getSearchParams(req);
1409
+ const limit = parseInt(params.get('limit') ?? '20', 10);
1410
+ return json(ctx.awarenessService.getSessionHistory(limit));
1411
+ },
1412
+ },
1413
+
1414
+ '/api/awareness/suggestions': {
1415
+ GET: (req: Request) => {
1416
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1417
+ const params = getSearchParams(req);
1418
+ const limit = parseInt(params.get('limit') ?? '20', 10);
1419
+ const type = params.get('type') as SuggestionType | null;
1420
+ return json(ctx.awarenessService.getRecentSuggestionsList(limit, type ?? undefined));
1421
+ },
1422
+ },
1423
+
1424
+ '/api/awareness/suggestions/:id/dismiss': {
1425
+ PATCH: (req: Request & { params: { id: string } }) => {
1426
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1427
+ ctx.awarenessService.dismissSuggestion(req.params.id);
1428
+ return json({ ok: true });
1429
+ },
1430
+ },
1431
+
1432
+ '/api/awareness/suggestions/:id/act': {
1433
+ PATCH: (req: Request & { params: { id: string } }) => {
1434
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1435
+ ctx.awarenessService.actOnSuggestion(req.params.id);
1436
+ return json({ ok: true });
1437
+ },
1438
+ },
1439
+
1440
+ '/api/awareness/report': {
1441
+ GET: async (req: Request) => {
1442
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1443
+ const params = getSearchParams(req);
1444
+ const date = params.get('date') ?? undefined;
1445
+ try {
1446
+ const report = await ctx.awarenessService.generateReport(date);
1447
+ return json(report);
1448
+ } catch (err) {
1449
+ return error(`Report generation failed: ${err instanceof Error ? err.message : err}`, 500);
1450
+ }
1451
+ },
1452
+ },
1453
+
1454
+ '/api/awareness/stats': {
1455
+ GET: (req: Request) => {
1456
+ const params = getSearchParams(req);
1457
+ const start = parseInt(params.get('start') ?? String(Date.now() - 24 * 60 * 60 * 1000), 10);
1458
+ const end = parseInt(params.get('end') ?? String(Date.now()), 10);
1459
+ return json(getCapturesInRange(start, end));
1460
+ },
1461
+ },
1462
+
1463
+ '/api/awareness/report/weekly': {
1464
+ GET: async (req: Request) => {
1465
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1466
+ try {
1467
+ const params = getSearchParams(req);
1468
+ const weekStart = params.get('weekStart') ?? undefined;
1469
+ const report = await ctx.awarenessService.generateWeeklyReport(weekStart);
1470
+ return json(report);
1471
+ } catch (err) {
1472
+ return error(`Weekly report error: ${err instanceof Error ? err.message : err}`);
1473
+ }
1474
+ },
1475
+ },
1476
+
1477
+ '/api/awareness/insights': {
1478
+ GET: (req: Request) => {
1479
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1480
+ try {
1481
+ const params = getSearchParams(req);
1482
+ const days = parseInt(params.get('days') ?? '7', 10) || 7;
1483
+ const insights = ctx.awarenessService.getBehavioralInsights(days);
1484
+ return json(insights);
1485
+ } catch (err) {
1486
+ return error(`Insights error: ${err instanceof Error ? err.message : err}`);
1487
+ }
1488
+ },
1489
+ },
1490
+
1491
+ '/api/awareness/toggle': {
1492
+ POST: async (req: Request) => {
1493
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1494
+ try {
1495
+ const body = await req.json() as { enabled: boolean };
1496
+ ctx.awarenessService.toggle(body.enabled);
1497
+ return json({ ok: true, enabled: body.enabled });
1498
+ } catch {
1499
+ return error('Invalid request body');
1500
+ }
1501
+ },
1502
+ },
1503
+
1504
+ // --- Workflows (M14) ---
1505
+ '/api/workflows': {
1506
+ GET: (req: Request) => {
1507
+ try {
1508
+ const { findWorkflows } = require('../vault/workflows.ts');
1509
+ const params = getSearchParams(req);
1510
+ const query: any = {};
1511
+ if (params.has('enabled')) query.enabled = params.get('enabled') === 'true';
1512
+ if (params.has('tag')) query.tag = params.get('tag');
1513
+ if (params.has('limit')) query.limit = parseInt(params.get('limit')!);
1514
+ return json(findWorkflows(query));
1515
+ } catch (err) { return error(`${err}`); }
1516
+ },
1517
+ POST: async (req: Request) => {
1518
+ try {
1519
+ const { createWorkflow, createVersion } = require('../vault/workflows.ts');
1520
+ const body = await req.json() as any;
1521
+ if (!body.name) return error('name is required');
1522
+ const wf = createWorkflow(body.name, {
1523
+ description: body.description,
1524
+ authority_level: body.authority_level,
1525
+ tags: body.tags,
1526
+ });
1527
+ if (body.definition) {
1528
+ createVersion(wf.id, body.definition, body.changelog ?? 'Initial version');
1529
+ }
1530
+ return json(wf, 201);
1531
+ } catch (err) { return error(`${err}`); }
1532
+ },
1533
+ },
1534
+
1535
+ '/api/workflows/nodes': {
1536
+ GET: () => {
1537
+ if (!ctx.nodeRegistry) return error('Node registry not available', 503);
1538
+ return json(ctx.nodeRegistry.list().map(n => ({
1539
+ type: n.type, label: n.label, description: n.description,
1540
+ category: n.category, icon: n.icon, color: n.color,
1541
+ configSchema: n.configSchema, inputs: n.inputs, outputs: n.outputs,
1542
+ })));
1543
+ },
1544
+ },
1545
+
1546
+ '/api/workflows/import': {
1547
+ POST: async (req: Request) => {
1548
+ try {
1549
+ const { importWorkflowYaml } = require('../workflows/yaml.ts');
1550
+ const { createWorkflow, createVersion, setVariable } = require('../vault/workflows.ts');
1551
+ const yamlText = await req.text();
1552
+ const imported = importWorkflowYaml(yamlText);
1553
+ const wf = createWorkflow(imported.name, {
1554
+ description: imported.description,
1555
+ authority_level: imported.authority_level,
1556
+ tags: imported.tags,
1557
+ });
1558
+ createVersion(wf.id, imported.definition, 'Imported');
1559
+ for (const [k, v] of Object.entries(imported.variables)) {
1560
+ setVariable(wf.id, k, v);
1561
+ }
1562
+ return json(wf, 201);
1563
+ } catch (err) { return error(`YAML import failed: ${err}`); }
1564
+ },
1565
+ },
1566
+
1567
+ '/api/workflows/:id': {
1568
+ GET: (req: Request) => {
1569
+ try {
1570
+ const { getWorkflow } = require('../vault/workflows.ts');
1571
+ const url = new URL(req.url);
1572
+ const id = url.pathname.split('/').pop()!;
1573
+ const wf = getWorkflow(id);
1574
+ if (!wf) return error('Workflow not found', 404);
1575
+ return json(wf);
1576
+ } catch (err) { return error(`${err}`); }
1577
+ },
1578
+ PATCH: async (req: Request) => {
1579
+ try {
1580
+ const { updateWorkflow } = require('../vault/workflows.ts');
1581
+ const url = new URL(req.url);
1582
+ const id = url.pathname.split('/').pop()!;
1583
+ const body = await req.json() as any;
1584
+ const updated = updateWorkflow(id, body);
1585
+ if (!updated) return error('Workflow not found', 404);
1586
+ return json(updated);
1587
+ } catch (err) { return error(`${err}`); }
1588
+ },
1589
+ DELETE: (req: Request) => {
1590
+ try {
1591
+ const { deleteWorkflow } = require('../vault/workflows.ts');
1592
+ const url = new URL(req.url);
1593
+ const id = url.pathname.split('/').pop()!;
1594
+ ctx.triggerManager?.unregisterWorkflow(id);
1595
+ deleteWorkflow(id);
1596
+ return json({ ok: true });
1597
+ } catch (err) { return error(`${err}`); }
1598
+ },
1599
+ },
1600
+
1601
+ '/api/workflows/:id/versions': {
1602
+ GET: (req: Request) => {
1603
+ try {
1604
+ const { getVersionHistory } = require('../vault/workflows.ts');
1605
+ const url = new URL(req.url);
1606
+ const parts = url.pathname.split('/');
1607
+ const id = parts[parts.length - 2];
1608
+ return json(getVersionHistory(id));
1609
+ } catch (err) { return error(`${err}`); }
1610
+ },
1611
+ POST: async (req: Request) => {
1612
+ try {
1613
+ const { createVersion } = require('../vault/workflows.ts');
1614
+ const url = new URL(req.url);
1615
+ const parts = url.pathname.split('/');
1616
+ const id = parts[parts.length - 2];
1617
+ const body = await req.json() as any;
1618
+ if (!body.definition) return error('definition is required');
1619
+ const version = createVersion(id, body.definition, body.changelog);
1620
+ return json(version, 201);
1621
+ } catch (err) { return error(`${err}`); }
1622
+ },
1623
+ },
1624
+
1625
+ '/api/workflows/:id/execute': {
1626
+ POST: async (req: Request) => {
1627
+ if (!ctx.workflowEngine) return error('Workflow engine not available', 503);
1628
+ try {
1629
+ const url = new URL(req.url);
1630
+ const parts = url.pathname.split('/');
1631
+ const id = parts[parts.length - 2];
1632
+ let triggerData: Record<string, unknown> = {};
1633
+ try { triggerData = await req.json() as any; } catch {}
1634
+ const execution = await ctx.workflowEngine.execute(id!, 'manual', triggerData);
1635
+ return json(execution, 201);
1636
+ } catch (err) { return error(`${err}`); }
1637
+ },
1638
+ },
1639
+
1640
+ '/api/workflows/:id/executions': {
1641
+ GET: (req: Request) => {
1642
+ try {
1643
+ const { findExecutions } = require('../vault/workflows.ts');
1644
+ const url = new URL(req.url);
1645
+ const parts = url.pathname.split('/');
1646
+ const id = parts[parts.length - 2];
1647
+ return json(findExecutions({ workflow_id: id }));
1648
+ } catch (err) { return error(`${err}`); }
1649
+ },
1650
+ },
1651
+
1652
+ '/api/workflows/:id/variables': {
1653
+ GET: (req: Request) => {
1654
+ try {
1655
+ const { getVariables } = require('../vault/workflows.ts');
1656
+ const url = new URL(req.url);
1657
+ const parts = url.pathname.split('/');
1658
+ const id = parts[parts.length - 2];
1659
+ return json(getVariables(id));
1660
+ } catch (err) { return error(`${err}`); }
1661
+ },
1662
+ PATCH: async (req: Request) => {
1663
+ try {
1664
+ const { setVariable, getVariables } = require('../vault/workflows.ts');
1665
+ const url = new URL(req.url);
1666
+ const parts = url.pathname.split('/');
1667
+ const id = parts[parts.length - 2];
1668
+ const body = await req.json() as Record<string, unknown>;
1669
+ for (const [key, value] of Object.entries(body)) {
1670
+ setVariable(id, key, value);
1671
+ }
1672
+ return json(getVariables(id));
1673
+ } catch (err) { return error(`${err}`); }
1674
+ },
1675
+ },
1676
+
1677
+ '/api/workflows/:id/export': {
1678
+ GET: (req: Request) => {
1679
+ try {
1680
+ const { getWorkflow, getLatestVersion, getVariables } = require('../vault/workflows.ts');
1681
+ const { exportWorkflowYaml } = require('../workflows/yaml.ts');
1682
+ const url = new URL(req.url);
1683
+ const parts = url.pathname.split('/');
1684
+ const id = parts[parts.length - 2];
1685
+ const wf = getWorkflow(id);
1686
+ if (!wf) return error('Workflow not found', 404);
1687
+ const version = getLatestVersion(id);
1688
+ if (!version) return error('No version found', 404);
1689
+ const vars = getVariables(id);
1690
+ const yaml = exportWorkflowYaml(wf, version, vars);
1691
+ return new Response(yaml, {
1692
+ headers: {
1693
+ 'Content-Type': 'text/yaml',
1694
+ 'Content-Disposition': `attachment; filename="${wf.name}.yaml"`,
1695
+ ...CORS,
1696
+ },
1697
+ });
1698
+ } catch (err) { return error(`${err}`); }
1699
+ },
1700
+ },
1701
+
1702
+ '/api/workflows/executions/:executionId': {
1703
+ GET: (req: Request) => {
1704
+ try {
1705
+ const { getExecution, getStepResults } = require('../vault/workflows.ts');
1706
+ const url = new URL(req.url);
1707
+ const executionId = url.pathname.split('/').pop()!;
1708
+ const exec = getExecution(executionId);
1709
+ if (!exec) return error('Execution not found', 404);
1710
+ const steps = getStepResults(executionId);
1711
+ return json({ ...exec, steps });
1712
+ } catch (err) { return error(`${err}`); }
1713
+ },
1714
+ },
1715
+
1716
+ '/api/workflows/executions/:executionId/cancel': {
1717
+ POST: async (req: Request) => {
1718
+ if (!ctx.workflowEngine) return error('Workflow engine not available', 503);
1719
+ try {
1720
+ const url = new URL(req.url);
1721
+ const parts = url.pathname.split('/');
1722
+ const executionId = parts[parts.length - 2];
1723
+ await ctx.workflowEngine.cancel(executionId!);
1724
+ return json({ ok: true });
1725
+ } catch (err) { return error(`${err}`); }
1726
+ },
1727
+ },
1728
+
1729
+ '/api/workflows/nl-chat': {
1730
+ POST: async (req: Request) => {
1731
+ if (!ctx.nlBuilder) return error('NL builder not available', 503);
1732
+ try {
1733
+ const body = await req.json() as { workflowId: string; message: string; history?: Array<{ role: string; content: string }> };
1734
+ const result = await ctx.nlBuilder.chat(
1735
+ body.workflowId,
1736
+ body.message,
1737
+ (body.history ?? []) as Array<{ role: 'user' | 'assistant'; content: string }>,
1738
+ );
1739
+ return json(result);
1740
+ } catch (err) { return error(`${err}`); }
1741
+ },
1742
+ },
1743
+
1744
+ '/api/workflows/suggest': {
1745
+ GET: async () => {
1746
+ if (!ctx.autoSuggest) return error('Auto-suggest not available', 503);
1747
+ try {
1748
+ const suggestions = await ctx.autoSuggest.generateSuggestions();
1749
+ return json(suggestions);
1750
+ } catch (err) { return error(`${err}`); }
1751
+ },
1752
+ },
1753
+
1754
+ '/api/workflows/suggest/:id/dismiss': {
1755
+ POST: async (req: Request) => {
1756
+ if (!ctx.autoSuggest) return error('Auto-suggest not available', 503);
1757
+ try {
1758
+ const url = new URL(req.url);
1759
+ const id = url.pathname.split('/').pop() === 'dismiss'
1760
+ ? url.pathname.split('/').slice(-2, -1)[0]
1761
+ : url.pathname.split('/').pop()!;
1762
+ ctx.autoSuggest.dismiss(id!);
1763
+ return json({ ok: true });
1764
+ } catch (err) { return error(`${err}`); }
1765
+ },
1766
+ },
1767
+
1768
+ '/api/webhooks/:id': {
1769
+ POST: async (req: Request) => {
1770
+ if (!ctx.webhookManager) return error('Webhook manager not available', 503);
1771
+ try {
1772
+ const url = new URL(req.url);
1773
+ const id = url.pathname.split('/').pop()!;
1774
+ return ctx.webhookManager.handleRequest(id, req);
1775
+ } catch (err) { return error(`${err}`); }
1776
+ },
1777
+ GET: async (req: Request) => {
1778
+ if (!ctx.webhookManager) return error('Webhook manager not available', 503);
1779
+ try {
1780
+ const url = new URL(req.url);
1781
+ const id = url.pathname.split('/').pop()!;
1782
+ return ctx.webhookManager.handleRequest(id, req);
1783
+ } catch (err) { return error(`${err}`); }
1784
+ },
1785
+ },
1786
+
1787
+ // ── Goals (M16) ─────────────────────────────────────────────────
1788
+
1789
+ '/api/goals': {
1790
+ GET: (req: Request) => {
1791
+ try {
1792
+ const url = new URL(req.url);
1793
+ const status = url.searchParams.get('status') ?? undefined;
1794
+ const level = url.searchParams.get('level') ?? undefined;
1795
+ const tag = url.searchParams.get('tag') ?? undefined;
1796
+ const health = url.searchParams.get('health') ?? undefined;
1797
+ const parent_id = url.searchParams.get('parent_id');
1798
+ const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);
1799
+ const goals = require('../vault/goals.ts');
1800
+ return json(goals.findGoals({
1801
+ status: status as any,
1802
+ level: level as any,
1803
+ tag,
1804
+ health: health as any,
1805
+ parent_id: parent_id === 'null' ? null : parent_id ?? undefined,
1806
+ limit,
1807
+ }));
1808
+ } catch (err) { return error(`${err}`); }
1809
+ },
1810
+ POST: async (req: Request) => {
1811
+ try {
1812
+ const body = await req.json() as Record<string, unknown>;
1813
+ const mode = body.mode as string | undefined;
1814
+
1815
+ // Natural language → OKR proposal (uses LLM)
1816
+ if (mode === 'propose') {
1817
+ const text = body.text as string;
1818
+ if (!text?.trim()) return error('text is required for propose mode', 400);
1819
+ const { NLGoalBuilder } = await import('../goals/nl-builder.ts');
1820
+ const llmManager = ctx.agentService.getLLMManager();
1821
+ const builder = new NLGoalBuilder(llmManager);
1822
+ const proposal = await builder.parseGoal(text.trim());
1823
+ return json(proposal);
1824
+ }
1825
+
1826
+ // Create goals from a confirmed proposal
1827
+ if (mode === 'create_from_proposal') {
1828
+ const proposal = body.proposal as any;
1829
+ if (!proposal?.objective?.title) return error('proposal with objective required', 400);
1830
+ const { NLGoalBuilder } = await import('../goals/nl-builder.ts');
1831
+ const llmManager = ctx.agentService.getLLMManager();
1832
+ const builder = new NLGoalBuilder(llmManager);
1833
+ const created = builder.createFromProposal(proposal, body.parent_id as string | undefined);
1834
+ return json(created, 201);
1835
+ }
1836
+
1837
+ // Quick create (direct)
1838
+ const title = body.title as string;
1839
+ const level = (body.level as string) ?? 'task';
1840
+ if (!title) return error('title is required', 400);
1841
+ const goals = require('../vault/goals.ts');
1842
+ const goal = goals.createGoal(title, level, body);
1843
+ return json(goal, 201);
1844
+ } catch (err) { return error(`${err}`); }
1845
+ },
1846
+ },
1847
+
1848
+ '/api/goals/roots': {
1849
+ GET: () => {
1850
+ try {
1851
+ const goals = require('../vault/goals.ts');
1852
+ return json(goals.getRootGoals());
1853
+ } catch (err) { return error(`${err}`); }
1854
+ },
1855
+ },
1856
+
1857
+ '/api/goals/overdue': {
1858
+ GET: () => {
1859
+ try {
1860
+ const goals = require('../vault/goals.ts');
1861
+ return json(goals.getOverdueGoals());
1862
+ } catch (err) { return error(`${err}`); }
1863
+ },
1864
+ },
1865
+
1866
+ '/api/goals/metrics': {
1867
+ GET: () => {
1868
+ try {
1869
+ const goals = require('../vault/goals.ts');
1870
+ return json(goals.getGoalMetrics());
1871
+ } catch (err) { return error(`${err}`); }
1872
+ },
1873
+ },
1874
+
1875
+ '/api/goals/reorder': {
1876
+ POST: async (req: Request) => {
1877
+ try {
1878
+ const body = await req.json() as { id: string; sort_order: number }[];
1879
+ const goals = require('../vault/goals.ts');
1880
+ goals.reorderGoals(body);
1881
+ return json({ ok: true });
1882
+ } catch (err) { return error(`${err}`); }
1883
+ },
1884
+ },
1885
+
1886
+ '/api/goals/check-ins': {
1887
+ GET: (req: Request) => {
1888
+ try {
1889
+ const url = new URL(req.url);
1890
+ const type = url.searchParams.get('type') as any;
1891
+ const limit = parseInt(url.searchParams.get('limit') ?? '10', 10);
1892
+ const goals = require('../vault/goals.ts');
1893
+ return json(goals.getRecentCheckIns(type ?? undefined, limit));
1894
+ } catch (err) { return error(`${err}`); }
1895
+ },
1896
+ },
1897
+
1898
+ '/api/goals/daily-actions': {
1899
+ GET: () => {
1900
+ try {
1901
+ const goals = require('../vault/goals.ts');
1902
+ return json(goals.findGoals({ level: 'daily_action', status: 'active', limit: 20 }));
1903
+ } catch (err) { return error(`${err}`); }
1904
+ },
1905
+ },
1906
+
1907
+ '/api/goals/:id': {
1908
+ GET: (req: Request) => {
1909
+ try {
1910
+ const url = new URL(req.url);
1911
+ const id = url.pathname.split('/').pop()!;
1912
+ const goals = require('../vault/goals.ts');
1913
+ const goal = goals.getGoal(id);
1914
+ if (!goal) return error('Goal not found', 404);
1915
+ return json(goal);
1916
+ } catch (err) { return error(`${err}`); }
1917
+ },
1918
+ PATCH: async (req: Request) => {
1919
+ try {
1920
+ const url = new URL(req.url);
1921
+ const id = url.pathname.split('/').pop()!;
1922
+ const body = await req.json() as Record<string, unknown>;
1923
+ const goals = require('../vault/goals.ts');
1924
+ const updated = goals.updateGoal(id, body);
1925
+ if (!updated) return error('Goal not found', 404);
1926
+ return json(updated);
1927
+ } catch (err) { return error(`${err}`); }
1928
+ },
1929
+ DELETE: (req: Request) => {
1930
+ try {
1931
+ const url = new URL(req.url);
1932
+ const id = url.pathname.split('/').pop()!;
1933
+ const goals = require('../vault/goals.ts');
1934
+ const deleted = goals.deleteGoal(id);
1935
+ if (!deleted) return error('Goal not found', 404);
1936
+ return json({ ok: true });
1937
+ } catch (err) { return error(`${err}`); }
1938
+ },
1939
+ },
1940
+
1941
+ '/api/goals/:id/tree': {
1942
+ GET: (req: Request) => {
1943
+ try {
1944
+ const url = new URL(req.url);
1945
+ const parts = url.pathname.split('/');
1946
+ const id = parts[parts.length - 2]!;
1947
+ const goals = require('../vault/goals.ts');
1948
+ return json(goals.getGoalTree(id));
1949
+ } catch (err) { return error(`${err}`); }
1950
+ },
1951
+ },
1952
+
1953
+ '/api/goals/:id/children': {
1954
+ GET: (req: Request) => {
1955
+ try {
1956
+ const url = new URL(req.url);
1957
+ const parts = url.pathname.split('/');
1958
+ const id = parts[parts.length - 2]!;
1959
+ const goals = require('../vault/goals.ts');
1960
+ return json(goals.getGoalChildren(id));
1961
+ } catch (err) { return error(`${err}`); }
1962
+ },
1963
+ },
1964
+
1965
+ '/api/goals/:id/score': {
1966
+ POST: async (req: Request) => {
1967
+ try {
1968
+ const url = new URL(req.url);
1969
+ const parts = url.pathname.split('/');
1970
+ const id = parts[parts.length - 2]!;
1971
+ const body = await req.json() as { score: number; reason: string; source?: string };
1972
+ const goals = require('../vault/goals.ts');
1973
+ const updated = goals.updateGoalScore(id, body.score, body.reason, body.source ?? 'user');
1974
+ if (!updated) return error('Goal not found', 404);
1975
+ return json(updated);
1976
+ } catch (err) { return error(`${err}`); }
1977
+ },
1978
+ },
1979
+
1980
+ '/api/goals/:id/status': {
1981
+ POST: async (req: Request) => {
1982
+ try {
1983
+ const url = new URL(req.url);
1984
+ const parts = url.pathname.split('/');
1985
+ const id = parts[parts.length - 2]!;
1986
+ const body = await req.json() as { status: string };
1987
+ const goals = require('../vault/goals.ts');
1988
+ const updated = goals.updateGoalStatus(id, body.status as any);
1989
+ if (!updated) return error('Goal not found', 404);
1990
+ return json(updated);
1991
+ } catch (err) { return error(`${err}`); }
1992
+ },
1993
+ },
1994
+
1995
+ '/api/goals/:id/health': {
1996
+ POST: async (req: Request) => {
1997
+ try {
1998
+ const url = new URL(req.url);
1999
+ const parts = url.pathname.split('/');
2000
+ const id = parts[parts.length - 2]!;
2001
+ const body = await req.json() as { health: string };
2002
+ const goals = require('../vault/goals.ts');
2003
+ const updated = goals.updateGoalHealth(id, body.health as any);
2004
+ if (!updated) return error('Goal not found', 404);
2005
+ return json(updated);
2006
+ } catch (err) { return error(`${err}`); }
2007
+ },
2008
+ },
2009
+
2010
+ '/api/goals/:id/progress': {
2011
+ GET: (req: Request) => {
2012
+ try {
2013
+ const url = new URL(req.url);
2014
+ const parts = url.pathname.split('/');
2015
+ const id = parts[parts.length - 2]!;
2016
+ const limit = parseInt(url.searchParams.get('limit') ?? '50', 10);
2017
+ const goals = require('../vault/goals.ts');
2018
+ return json(goals.getProgressHistory(id, limit));
2019
+ } catch (err) { return error(`${err}`); }
2020
+ },
2021
+ },
2022
+
2023
+ // --- Sidecars ---
2024
+ '/api/sidecars': {
2025
+ GET: () => {
2026
+ try {
2027
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2028
+ return json(ctx.sidecarManager.listSidecars());
2029
+ } catch (err) { return error(`${err}`); }
2030
+ },
2031
+ },
2032
+
2033
+ '/api/sidecars/enroll': {
2034
+ POST: async (req: Request) => {
2035
+ try {
2036
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2037
+ const body = await req.json() as { name?: string };
2038
+ if (!body.name) return error('Missing "name" field');
2039
+ const result = await ctx.sidecarManager.enrollSidecar(body.name);
2040
+ return json(result, 201);
2041
+ } catch (err) {
2042
+ const msg = err instanceof Error ? err.message : String(err);
2043
+ if (msg.includes('already enrolled') || msg.includes('may only contain')) {
2044
+ return error(msg, 409);
2045
+ }
2046
+ return error(msg);
2047
+ }
2048
+ },
2049
+ },
2050
+
2051
+ '/api/sidecars/.well-known/jwks.json': {
2052
+ GET: () => {
2053
+ try {
2054
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2055
+ return json(ctx.sidecarManager.getJwks());
2056
+ } catch (err) { return error(`${err}`); }
2057
+ },
2058
+ },
2059
+
2060
+ '/api/sidecars/:id/config': {
2061
+ GET: async (req: Request) => {
2062
+ try {
2063
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2064
+ const url = new URL(req.url);
2065
+ const parts = url.pathname.split('/');
2066
+ const id = parts[parts.length - 2]!;
2067
+ if (!ctx.sidecarManager.isConnected(id)) {
2068
+ return error('Sidecar is not connected', 409);
2069
+ }
2070
+ const result = await ctx.sidecarManager.dispatchRPC(id, 'get_config', {});
2071
+ return json(result);
2072
+ } catch (err) { return error(`${err}`, 500); }
2073
+ },
2074
+ PATCH: async (req: Request) => {
2075
+ try {
2076
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2077
+ const url = new URL(req.url);
2078
+ const parts = url.pathname.split('/');
2079
+ const id = parts[parts.length - 2]!;
2080
+ if (!ctx.sidecarManager.isConnected(id)) {
2081
+ return error('Sidecar is not connected', 409);
2082
+ }
2083
+ const body = await req.json() as Record<string, unknown>;
2084
+ delete body.token;
2085
+ const result = await ctx.sidecarManager.dispatchRPC(id, 'update_config', body);
2086
+ return json(result);
2087
+ } catch (err) { return error(`${err}`, 500); }
2088
+ },
2089
+ },
2090
+
2091
+ '/api/sidecars/:id': {
2092
+ GET: (req: Request) => {
2093
+ try {
2094
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2095
+ const url = new URL(req.url);
2096
+ const id = url.pathname.split('/').pop()!;
2097
+ const sidecar = ctx.sidecarManager.getSidecar(id);
2098
+ if (!sidecar) return error('Sidecar not found', 404);
2099
+ return json(sidecar);
2100
+ } catch (err) { return error(`${err}`); }
2101
+ },
2102
+ DELETE: (req: Request) => {
2103
+ try {
2104
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2105
+ const url = new URL(req.url);
2106
+ const id = url.pathname.split('/').pop()!;
2107
+ const revoked = ctx.sidecarManager.revokeSidecar(id);
2108
+ if (!revoked) return error('Sidecar not found or already revoked', 404);
2109
+ return json({ success: true });
2110
+ } catch (err) { return error(`${err}`); }
2111
+ },
2112
+ },
2113
+
2114
+ // --- CORS preflight ---
2115
+ '/api/*': {
2116
+ OPTIONS: () => new Response(null, { status: 204, headers: CORS }),
2117
+ },
2118
+ };
2119
+ }