@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.
- package/LICENSE +153 -0
- package/README.md +428 -0
- package/bin/jarvis.ts +449 -0
- package/package.json +79 -0
- package/roles/activity-observer.yaml +60 -0
- package/roles/ceo-founder.yaml +144 -0
- package/roles/chief-of-staff.yaml +158 -0
- package/roles/dev-lead.yaml +182 -0
- package/roles/executive-assistant.yaml +77 -0
- package/roles/marketing-director.yaml +168 -0
- package/roles/personal-assistant.yaml +266 -0
- package/roles/research-specialist.yaml +60 -0
- package/roles/specialists/content-writer.yaml +53 -0
- package/roles/specialists/customer-support.yaml +57 -0
- package/roles/specialists/data-analyst.yaml +57 -0
- package/roles/specialists/financial-analyst.yaml +56 -0
- package/roles/specialists/hr-specialist.yaml +55 -0
- package/roles/specialists/legal-advisor.yaml +58 -0
- package/roles/specialists/marketing-strategist.yaml +56 -0
- package/roles/specialists/project-coordinator.yaml +55 -0
- package/roles/specialists/research-analyst.yaml +58 -0
- package/roles/specialists/software-engineer.yaml +57 -0
- package/roles/specialists/system-administrator.yaml +57 -0
- package/roles/system-admin.yaml +76 -0
- package/scripts/ensure-bun.cjs +16 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +261 -0
- package/src/actions/browser/session.ts +506 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +363 -0
- package/src/actions/tools/builtin.ts +950 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/documents.ts +169 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +31 -0
- package/src/actions/tools/registry.ts +173 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +592 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +309 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +301 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +417 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +238 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +827 -0
- package/src/cli/uninstall.test.ts +37 -0
- package/src/cli/uninstall.ts +202 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/dashboard-auth.ts +75 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +149 -0
- package/src/comms/voice.test.ts +504 -0
- package/src/comms/voice.ts +341 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +669 -0
- package/src/config/README.md +389 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +183 -0
- package/src/config/loader.ts +148 -0
- package/src/config/types.ts +293 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +667 -0
- package/src/daemon/api-routes.ts +3067 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/dashboard-auth.test.ts +170 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/flock.c +7 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1070 -0
- package/src/daemon/llm-settings.test.ts +78 -0
- package/src/daemon/llm-settings.ts +450 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.test.ts +283 -0
- package/src/daemon/pid.ts +224 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +926 -0
- package/src/global.d.ts +4 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +407 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +400 -0
- package/src/llm/gemini.ts +380 -0
- package/src/llm/groq.ts +406 -0
- package/src/llm/history.ts +147 -0
- package/src/llm/index.ts +21 -0
- package/src/llm/manager.ts +226 -0
- package/src/llm/ollama.ts +316 -0
- package/src/llm/openai.ts +411 -0
- package/src/llm/openrouter.ts +390 -0
- package/src/llm/provider.test.ts +487 -0
- package/src/llm/provider.ts +61 -0
- package/src/llm/test.ts +88 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +120 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +218 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +195 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/sites/builder-tools.ts +215 -0
- package/src/sites/dev-server-manager.ts +286 -0
- package/src/sites/fixtures/security-test-site/.jarvis-project.json +6 -0
- package/src/sites/fixtures/security-test-site/Makefile +15 -0
- package/src/sites/fixtures/security-test-site/README.md +18 -0
- package/src/sites/fixtures/security-test-site/index.html +12 -0
- package/src/sites/fixtures/security-test-site/index.ts +16 -0
- package/src/sites/fixtures/security-test-site/package.json +13 -0
- package/src/sites/fixtures/security-test-site/src/app.tsx +780 -0
- package/src/sites/fixtures/security-test-site/tsconfig.json +10 -0
- package/src/sites/git-manager.ts +240 -0
- package/src/sites/github-manager.ts +355 -0
- package/src/sites/index.ts +25 -0
- package/src/sites/project-manager.ts +389 -0
- package/src/sites/proxy.ts +133 -0
- package/src/sites/service.ts +136 -0
- package/src/sites/templates.ts +169 -0
- package/src/sites/types.ts +89 -0
- package/src/user/profile-followup.test.ts +84 -0
- package/src/user/profile-followup.ts +185 -0
- package/src/user/profile.ts +224 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +270 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/dashboard-sessions.ts +44 -0
- package/src/vault/documents.ts +130 -0
- package/src/vault/entities.ts +185 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +139 -0
- package/src/vault/retrieval.ts +258 -0
- package/src/vault/schema.ts +709 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/user-profile.test.ts +113 -0
- package/src/vault/user-profile.ts +176 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/webapp-template-seeds.ts +116 -0
- package/src/vault/webapp-templates.ts +244 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-3gr23jt9.js +112614 -0
- package/ui/dist/index-9vmj8127.css +14239 -0
- package/ui/dist/index-hy9pc1gm.js +112873 -0
- package/ui/dist/index-j2ep5d1w.js +112374 -0
- package/ui/dist/index-jt00vjqs.js +112858 -0
- package/ui/dist/index-k9ymx5qb.js +112374 -0
- package/ui/dist/index.html +16 -0
- package/ui/public/audio/pcm-capture-processor.js +11 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|