daemora 1.0.5 → 1.0.7
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/SOUL.md +6 -4
- package/config/mcp.json +126 -66
- package/daemora-ui/dist/assets/index-BiMfB4bx.js +90 -0
- package/daemora-ui/dist/assets/index-DP95eMOr.css +1 -0
- package/daemora-ui/dist/favicon.svg +29 -0
- package/daemora-ui/dist/index.html +16 -0
- package/package.json +6 -5
- package/src/agents/SubAgentManager.js +81 -8
- package/src/{systemPrompt.js → agents/systemPrompt.js} +91 -35
- package/src/cli.js +162 -5
- package/src/config/default.js +5 -1
- package/src/core/Compaction.js +27 -9
- package/src/core/TaskRunner.js +1 -1
- package/src/index.js +404 -18
- package/src/models/ModelRouter.js +7 -3
- package/src/setup/wizard.js +50 -71
- package/src/skills/SkillLoader.js +28 -0
- package/src/tools/index.js +1 -1
- package/src/utils/Embeddings.js +84 -7
package/src/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import { mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
5
6
|
import { toolFunctions } from "./tools/index.js";
|
|
6
7
|
import { getSession, listSessions, createSession, clearSession } from "./services/sessions.js";
|
|
7
8
|
import { config } from "./config/default.js";
|
|
@@ -54,9 +55,8 @@ if (config.cleanupAfterDays > 0) {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
// Initialize task system
|
|
58
|
+
// Initialize task system (TaskRunner starts after full init — see startup sequence below)
|
|
58
59
|
taskQueue.init();
|
|
59
|
-
taskRunner.start();
|
|
60
60
|
supervisor.start();
|
|
61
61
|
auditLog.start();
|
|
62
62
|
scheduler.start();
|
|
@@ -82,9 +82,20 @@ if (process.env.OPENAI_COMPAT_ENABLED === "true") {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// --- Security middleware ---
|
|
85
|
+
|
|
86
|
+
// Security headers on all responses
|
|
87
|
+
app.use((req, res, next) => {
|
|
88
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
89
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
90
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
91
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
92
|
+
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
93
|
+
next();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Localhost-only: reject non-local IP addresses
|
|
85
97
|
const localOnly = (req, res, next) => {
|
|
86
98
|
const remoteAddress = req.socket.remoteAddress;
|
|
87
|
-
// Support both IPv4 and IPv6 localhost
|
|
88
99
|
if (remoteAddress === "127.0.0.1" || remoteAddress === "::ffff:127.0.0.1" || remoteAddress === "::1") {
|
|
89
100
|
next();
|
|
90
101
|
} else {
|
|
@@ -93,13 +104,86 @@ const localOnly = (req, res, next) => {
|
|
|
93
104
|
}
|
|
94
105
|
};
|
|
95
106
|
|
|
96
|
-
//
|
|
107
|
+
// Origin validation: block DNS rebinding and cross-origin browser attacks.
|
|
108
|
+
// Browsers always send Origin on cross-origin requests. A malicious page on
|
|
109
|
+
// evil.com making fetch("http://localhost:8081/api/...") will have Origin: https://evil.com
|
|
110
|
+
// which we reject. Same-origin requests from our UI have no Origin or matching localhost.
|
|
111
|
+
const originGuard = (req, res, next) => {
|
|
112
|
+
const origin = req.headers.origin;
|
|
113
|
+
if (!origin) return next(); // Same-origin requests (no Origin header) — safe
|
|
114
|
+
|
|
115
|
+
// Allow only our own localhost origins
|
|
116
|
+
const allowedOrigins = [
|
|
117
|
+
`http://localhost:${config.port}`,
|
|
118
|
+
`http://127.0.0.1:${config.port}`,
|
|
119
|
+
`http://[::1]:${config.port}`,
|
|
120
|
+
];
|
|
121
|
+
// Also allow Vite dev server (common dev ports)
|
|
122
|
+
for (const devPort of [5173, 5174, 3000, 3001]) {
|
|
123
|
+
allowedOrigins.push(`http://localhost:${devPort}`);
|
|
124
|
+
allowedOrigins.push(`http://127.0.0.1:${devPort}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (allowedOrigins.includes(origin)) {
|
|
128
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
129
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
130
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
131
|
+
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
132
|
+
return next();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.warn(`[Security] Blocked cross-origin request from ${origin}`);
|
|
136
|
+
res.status(403).json({ error: "Cross-origin request blocked." });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// --- API Token auth ---
|
|
140
|
+
// Auto-generated on first start, stored on disk. Required for all /api/* requests.
|
|
141
|
+
// The UI receives the token via server-injected <meta> tag (no login needed).
|
|
142
|
+
// Other local tools (curl, scripts) read it from data/auth-token or pass via header.
|
|
143
|
+
const AUTH_TOKEN_PATH = join(config.dataDir, "auth-token");
|
|
144
|
+
|
|
145
|
+
function getOrCreateAuthToken() {
|
|
146
|
+
if (existsSync(AUTH_TOKEN_PATH)) {
|
|
147
|
+
const token = readFileSync(AUTH_TOKEN_PATH, "utf-8").trim();
|
|
148
|
+
if (token.length >= 32) return token;
|
|
149
|
+
}
|
|
150
|
+
const token = randomBytes(32).toString("hex");
|
|
151
|
+
mkdirSync(dirname(AUTH_TOKEN_PATH), { recursive: true });
|
|
152
|
+
writeFileSync(AUTH_TOKEN_PATH, token, { mode: 0o600 });
|
|
153
|
+
console.log("[Security] Generated new API auth token");
|
|
154
|
+
return token;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const API_TOKEN = getOrCreateAuthToken();
|
|
158
|
+
|
|
159
|
+
const tokenAuth = (req, res, next) => {
|
|
160
|
+
// Health endpoint is public (monitoring/readiness probes)
|
|
161
|
+
if (req.path === "/api/health") return next();
|
|
162
|
+
|
|
163
|
+
// Check Authorization: Bearer <token> header
|
|
164
|
+
const authHeader = req.headers.authorization;
|
|
165
|
+
if (authHeader === `Bearer ${API_TOKEN}`) return next();
|
|
166
|
+
|
|
167
|
+
// Check X-Auth-Token header (simpler for scripts/curl)
|
|
168
|
+
if (req.headers["x-auth-token"] === API_TOKEN) return next();
|
|
169
|
+
|
|
170
|
+
// Check ?token= query param (for SSE/EventSource which can't set headers)
|
|
171
|
+
if (req.query.token === API_TOKEN) return next();
|
|
172
|
+
|
|
173
|
+
console.warn(`[Security] Rejected unauthenticated request: ${req.method} ${req.path}`);
|
|
174
|
+
res.status(401).json({ error: "Authentication required. Include Authorization: Bearer <token> header." });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Apply security to all API routes: IP check → origin check → token auth
|
|
97
178
|
app.use("/api", localOnly);
|
|
179
|
+
app.use("/api", originGuard);
|
|
180
|
+
app.use("/api", tokenAuth);
|
|
98
181
|
|
|
99
182
|
// --- Health check ---
|
|
100
183
|
app.get("/api/health", (req, res) => {
|
|
101
184
|
res.json({
|
|
102
|
-
status: "ok",
|
|
185
|
+
status: _serverReady ? "ok" : "starting",
|
|
186
|
+
ready: _serverReady,
|
|
103
187
|
uptime: process.uptime(),
|
|
104
188
|
timestamp: new Date().toISOString(),
|
|
105
189
|
tools: Object.keys(toolFunctions).length,
|
|
@@ -502,10 +586,29 @@ app.get("/api/audit", (req, res) => {
|
|
|
502
586
|
app.get("/api/mcp", (req, res) => {
|
|
503
587
|
const cfg = mcpManager.readConfig().mcpServers || {};
|
|
504
588
|
const live = mcpManager.list();
|
|
589
|
+
const isPlaceholder = (v) => !v || v.startsWith("YOUR_") || v === "" || v.startsWith("${");
|
|
590
|
+
// Detect placeholder patterns in command args (e.g. connection strings, paths with dummy values)
|
|
591
|
+
const isArgPlaceholder = (v) => {
|
|
592
|
+
if (typeof v !== "string") return false;
|
|
593
|
+
return /user:pass@/i.test(v) || /\/Users\/you\//i.test(v) || /YOUR_/i.test(v)
|
|
594
|
+
|| /your-.*-here/i.test(v) || /example\.com/i.test(v) || /changeme/i.test(v)
|
|
595
|
+
|| /placeholder/i.test(v) || /xxx/i.test(v);
|
|
596
|
+
};
|
|
505
597
|
const servers = Object.entries(cfg)
|
|
506
598
|
.filter(([k]) => !k.startsWith("_comment"))
|
|
507
599
|
.map(([name, serverCfg]) => {
|
|
508
600
|
const liveEntry = live.find(s => s.name === name);
|
|
601
|
+
// Check if any env/header values are unconfigured placeholders
|
|
602
|
+
const envEntries = serverCfg.env ? Object.entries(serverCfg.env) : [];
|
|
603
|
+
const headerEntries = serverCfg.headers ? Object.entries(serverCfg.headers) : [];
|
|
604
|
+
// Also check args for placeholder patterns
|
|
605
|
+
const args = serverCfg.args || [];
|
|
606
|
+
const placeholderArgs = args
|
|
607
|
+
.map((v, i) => isArgPlaceholder(v) ? { index: i, value: v } : null)
|
|
608
|
+
.filter(Boolean);
|
|
609
|
+
const needsConfig = envEntries.some(([, v]) => isPlaceholder(v))
|
|
610
|
+
|| headerEntries.some(([, v]) => isPlaceholder(v))
|
|
611
|
+
|| placeholderArgs.length > 0;
|
|
509
612
|
return {
|
|
510
613
|
name,
|
|
511
614
|
enabled: serverCfg.enabled !== false,
|
|
@@ -517,6 +620,8 @@ app.get("/api/mcp", (req, res) => {
|
|
|
517
620
|
description: serverCfg.description || null,
|
|
518
621
|
envKeys: serverCfg.env ? Object.keys(serverCfg.env) : [],
|
|
519
622
|
headerKeys: serverCfg.headers ? Object.keys(serverCfg.headers) : [],
|
|
623
|
+
placeholderArgs,
|
|
624
|
+
needsConfig,
|
|
520
625
|
};
|
|
521
626
|
});
|
|
522
627
|
res.json({ servers });
|
|
@@ -551,6 +656,39 @@ app.delete("/api/mcp/:name", async (req, res) => {
|
|
|
551
656
|
}
|
|
552
657
|
});
|
|
553
658
|
|
|
659
|
+
// Update MCP server credentials (env vars or headers)
|
|
660
|
+
app.patch("/api/mcp/:name", async (req, res) => {
|
|
661
|
+
const { name } = req.params;
|
|
662
|
+
const { env, headers: hdrs, args: argUpdates } = req.body;
|
|
663
|
+
try {
|
|
664
|
+
const mcpConfig = mcpManager.readConfig();
|
|
665
|
+
const serverCfg = mcpConfig.mcpServers?.[name];
|
|
666
|
+
if (!serverCfg) return res.status(404).json({ error: `Server "${name}" not found` });
|
|
667
|
+
|
|
668
|
+
if (env && typeof env === "object") {
|
|
669
|
+
serverCfg.env = { ...(serverCfg.env || {}), ...env };
|
|
670
|
+
}
|
|
671
|
+
if (hdrs && typeof hdrs === "object") {
|
|
672
|
+
serverCfg.headers = { ...(serverCfg.headers || {}), ...hdrs };
|
|
673
|
+
}
|
|
674
|
+
// Support updating specific args by index (e.g. connection strings)
|
|
675
|
+
if (argUpdates && typeof argUpdates === "object") {
|
|
676
|
+
if (!serverCfg.args) serverCfg.args = [];
|
|
677
|
+
for (const [indexStr, value] of Object.entries(argUpdates)) {
|
|
678
|
+
const idx = parseInt(indexStr, 10);
|
|
679
|
+
if (!isNaN(idx) && idx >= 0 && idx < serverCfg.args.length) {
|
|
680
|
+
serverCfg.args[idx] = value;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
mcpConfig.mcpServers[name] = serverCfg;
|
|
685
|
+
mcpManager.writeConfig(mcpConfig);
|
|
686
|
+
res.json({ message: `Credentials updated for "${name}"` });
|
|
687
|
+
} catch (err) {
|
|
688
|
+
res.status(400).json({ error: err.message });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
554
692
|
// Enable / disable / reload an MCP server
|
|
555
693
|
app.post("/api/mcp/:name/:action", async (req, res) => {
|
|
556
694
|
const { name, action } = req.params;
|
|
@@ -694,6 +832,189 @@ app.post("/api/approvals/:id", (req, res) => {
|
|
|
694
832
|
res.json({ message: `Approval ${req.params.id} → ${decision}` });
|
|
695
833
|
});
|
|
696
834
|
|
|
835
|
+
// --- Settings endpoint (read/write .env vars) ---
|
|
836
|
+
app.get("/api/settings", (req, res) => {
|
|
837
|
+
const envPath = join(__dirname, "..", ".env");
|
|
838
|
+
const examplePath = join(__dirname, "..", ".env.example");
|
|
839
|
+
|
|
840
|
+
// Parse current .env
|
|
841
|
+
const envVars = {};
|
|
842
|
+
if (existsSync(envPath)) {
|
|
843
|
+
const lines = readFileSync(envPath, "utf-8").split("\n");
|
|
844
|
+
for (const line of lines) {
|
|
845
|
+
const trimmed = line.trim();
|
|
846
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
847
|
+
const eqIdx = trimmed.indexOf("=");
|
|
848
|
+
if (eqIdx === -1) continue;
|
|
849
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
850
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
851
|
+
envVars[key] = val;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Parse .env.example for available vars with sections
|
|
856
|
+
const available = [];
|
|
857
|
+
if (existsSync(examplePath)) {
|
|
858
|
+
const lines = readFileSync(examplePath, "utf-8").split("\n");
|
|
859
|
+
let section = "General";
|
|
860
|
+
for (const line of lines) {
|
|
861
|
+
const trimmed = line.trim();
|
|
862
|
+
if (trimmed.startsWith("# ===")) {
|
|
863
|
+
section = trimmed.replace(/^# =+\s*/, "").replace(/\s*=+$/, "");
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
867
|
+
const eqIdx = trimmed.indexOf("=");
|
|
868
|
+
if (eqIdx === -1) continue;
|
|
869
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
870
|
+
available.push({ key, section });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Mask values for security
|
|
875
|
+
const masked = {};
|
|
876
|
+
for (const [key, val] of Object.entries(envVars)) {
|
|
877
|
+
if (!val) { masked[key] = ""; continue; }
|
|
878
|
+
masked[key] = val.length <= 4 ? "****" : val.slice(0, 4) + "*".repeat(Math.min(val.length - 4, 20));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
res.json({ vars: masked, available });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
app.put("/api/settings", (req, res) => {
|
|
885
|
+
const { updates } = req.body; // { KEY: "value", KEY2: "value2" }
|
|
886
|
+
if (!updates || typeof updates !== "object") {
|
|
887
|
+
return res.status(400).json({ error: "updates object is required" });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const envPath = join(__dirname, "..", ".env");
|
|
891
|
+
let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
892
|
+
|
|
893
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
894
|
+
// Validate key format (alphanumeric + underscore only)
|
|
895
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue;
|
|
896
|
+
const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=.*$`, "m");
|
|
897
|
+
if (regex.test(content)) {
|
|
898
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
899
|
+
} else {
|
|
900
|
+
content = content.trimEnd() + `\n${key}=${value}\n`;
|
|
901
|
+
}
|
|
902
|
+
// Also update process.env so changes take effect without restart
|
|
903
|
+
process.env[key] = value;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
writeFileSync(envPath, content, "utf-8");
|
|
907
|
+
|
|
908
|
+
res.json({ message: `Updated ${Object.keys(updates).length} variable(s)`, updated: Object.keys(updates) });
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// --- User Profile endpoints ---
|
|
912
|
+
app.get("/api/profile", (req, res) => {
|
|
913
|
+
const profilePath = join(config.dataDir, "user-profile.json");
|
|
914
|
+
if (!existsSync(profilePath)) return res.json({});
|
|
915
|
+
try {
|
|
916
|
+
const profile = JSON.parse(readFileSync(profilePath, "utf-8"));
|
|
917
|
+
res.json(profile);
|
|
918
|
+
} catch {
|
|
919
|
+
res.json({});
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
app.put("/api/profile", (req, res) => {
|
|
924
|
+
const { name, personality, tone, instructions, subAgentModel } = req.body;
|
|
925
|
+
const profilePath = join(config.dataDir, "user-profile.json");
|
|
926
|
+
const profile = { name: name || "", personality: personality || "", tone: tone || "", instructions: instructions || "", subAgentModel: subAgentModel || "" };
|
|
927
|
+
mkdirSync(dirname(profilePath), { recursive: true });
|
|
928
|
+
writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf-8");
|
|
929
|
+
// Apply sub-agent model to runtime so it takes effect immediately
|
|
930
|
+
if (subAgentModel) {
|
|
931
|
+
process.env.SUB_AGENT_MODEL = subAgentModel;
|
|
932
|
+
} else {
|
|
933
|
+
delete process.env.SUB_AGENT_MODEL;
|
|
934
|
+
}
|
|
935
|
+
res.json({ message: "Profile saved", profile });
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// --- Custom Skills endpoints ---
|
|
939
|
+
app.get("/api/skills/custom", (req, res) => {
|
|
940
|
+
const customDir = join(config.skillsDir, "custom");
|
|
941
|
+
if (!existsSync(customDir)) return res.json({ skills: [] });
|
|
942
|
+
const files = [];
|
|
943
|
+
try {
|
|
944
|
+
const entries = readdirSync(customDir);
|
|
945
|
+
for (const f of entries) {
|
|
946
|
+
if (!f.endsWith(".md")) continue;
|
|
947
|
+
const content = readFileSync(join(customDir, f), "utf-8");
|
|
948
|
+
// Parse frontmatter
|
|
949
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
950
|
+
const meta = {};
|
|
951
|
+
if (fmMatch) {
|
|
952
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
953
|
+
const idx = line.indexOf(":");
|
|
954
|
+
if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
files.push({
|
|
958
|
+
name: meta.name || f.replace(".md", ""),
|
|
959
|
+
description: meta.description || "",
|
|
960
|
+
triggers: meta.triggers || "",
|
|
961
|
+
filename: f,
|
|
962
|
+
content: fmMatch ? fmMatch[2].trim() : content,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
} catch { /* ignore */ }
|
|
966
|
+
res.json({ skills: files });
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
app.post("/api/skills/custom", (req, res) => {
|
|
970
|
+
const { name, description, triggers, content } = req.body;
|
|
971
|
+
if (!name) return res.status(400).json({ error: "name is required" });
|
|
972
|
+
if (!content) return res.status(400).json({ error: "content is required" });
|
|
973
|
+
|
|
974
|
+
// Sanitize filename
|
|
975
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
|
|
976
|
+
const customDir = join(config.skillsDir, "custom");
|
|
977
|
+
mkdirSync(customDir, { recursive: true });
|
|
978
|
+
|
|
979
|
+
const filePath = join(customDir, `${safeName}.md`);
|
|
980
|
+
const frontmatter = `---\nname: ${safeName}\ndescription: ${description || ""}\n${triggers ? `triggers: ${triggers}\n` : ""}---\n\n`;
|
|
981
|
+
writeFileSync(filePath, frontmatter + content, "utf-8");
|
|
982
|
+
|
|
983
|
+
// Reload skills so new skill is discoverable
|
|
984
|
+
skillLoader.reload();
|
|
985
|
+
|
|
986
|
+
res.status(201).json({ message: "Custom skill created", name: safeName });
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
app.delete("/api/skills/custom/:name", (req, res) => {
|
|
990
|
+
const safeName = req.params.name.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
|
|
991
|
+
const filePath = join(config.skillsDir, "custom", `${safeName}.md`);
|
|
992
|
+
if (!existsSync(filePath)) return res.status(404).json({ error: "Skill not found" });
|
|
993
|
+
|
|
994
|
+
unlinkSync(filePath);
|
|
995
|
+
skillLoader.reload();
|
|
996
|
+
res.json({ message: "Custom skill deleted" });
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// --- Memory endpoints ---
|
|
1000
|
+
app.get("/api/memory", (req, res) => {
|
|
1001
|
+
const memoryPath = config.memoryPath;
|
|
1002
|
+
if (!existsSync(memoryPath)) return res.json({ content: "" });
|
|
1003
|
+
try {
|
|
1004
|
+
const content = readFileSync(memoryPath, "utf-8");
|
|
1005
|
+
res.json({ content });
|
|
1006
|
+
} catch {
|
|
1007
|
+
res.json({ content: "" });
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
app.put("/api/memory", (req, res) => {
|
|
1012
|
+
const { content } = req.body;
|
|
1013
|
+
if (content === undefined) return res.status(400).json({ error: "content is required" });
|
|
1014
|
+
writeFileSync(config.memoryPath, content, "utf-8");
|
|
1015
|
+
res.json({ message: "Memory saved" });
|
|
1016
|
+
});
|
|
1017
|
+
|
|
697
1018
|
// --- Costs endpoint ---
|
|
698
1019
|
app.get("/api/costs/today", (req, res) => {
|
|
699
1020
|
res.json({
|
|
@@ -704,42 +1025,107 @@ app.get("/api/costs/today", (req, res) => {
|
|
|
704
1025
|
});
|
|
705
1026
|
});
|
|
706
1027
|
|
|
707
|
-
// --- Static UI ---
|
|
1028
|
+
// --- Static UI (with auth token injection) ---
|
|
708
1029
|
const uiPath = join(__dirname, "..", "daemora-ui", "dist");
|
|
709
1030
|
if (existsSync(uiPath)) {
|
|
710
|
-
|
|
711
|
-
|
|
1031
|
+
const indexHtmlPath = join(uiPath, "index.html");
|
|
1032
|
+
let indexHtml = existsSync(indexHtmlPath) ? readFileSync(indexHtmlPath, "utf-8") : "";
|
|
1033
|
+
|
|
1034
|
+
// Inject auth token as a <meta> tag so the UI can read it without a login flow.
|
|
1035
|
+
// Safe because the HTML is only served to localhost (localOnly middleware).
|
|
1036
|
+
const tokenMeta = `<meta name="api-token" content="${API_TOKEN}" />`;
|
|
1037
|
+
if (indexHtml && !indexHtml.includes('name="api-token"')) {
|
|
1038
|
+
indexHtml = indexHtml.replace("</head>", ` ${tokenMeta}\n </head>`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Serve static assets normally
|
|
1042
|
+
app.use(express.static(uiPath, { index: false })); // index:false so we handle index.html ourselves
|
|
1043
|
+
|
|
1044
|
+
// Serve token-injected index.html for all UI routes
|
|
712
1045
|
app.get(/.*/, (req, res, next) => {
|
|
713
1046
|
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") || req.path.startsWith("/voice/") || req.path.startsWith("/a2a/") || req.path.startsWith("/hooks/") || req.path.startsWith("/v1/")) {
|
|
714
1047
|
return next();
|
|
715
1048
|
}
|
|
716
|
-
res.
|
|
1049
|
+
res.setHeader("Content-Type", "text/html");
|
|
1050
|
+
res.send(indexHtml);
|
|
717
1051
|
});
|
|
718
1052
|
console.log(`[Server] Serving UI from ${uiPath}`);
|
|
719
1053
|
}
|
|
720
1054
|
|
|
1055
|
+
// --- Load user profile settings on startup ---
|
|
1056
|
+
try {
|
|
1057
|
+
const profilePath = join(config.dataDir, "user-profile.json");
|
|
1058
|
+
if (existsSync(profilePath)) {
|
|
1059
|
+
const p = JSON.parse(readFileSync(profilePath, "utf-8"));
|
|
1060
|
+
if (p.subAgentModel && !process.env.SUB_AGENT_MODEL) {
|
|
1061
|
+
process.env.SUB_AGENT_MODEL = p.subAgentModel;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} catch { /* ignore */ }
|
|
1065
|
+
|
|
1066
|
+
// --- Server readiness gate ---
|
|
1067
|
+
// The server must fully initialize before processing user messages.
|
|
1068
|
+
// Skills, MCP, embeddings, and channels all need to load first.
|
|
1069
|
+
// Requests that arrive before ready get a 503 with a clear message.
|
|
1070
|
+
let _serverReady = false;
|
|
1071
|
+
|
|
1072
|
+
// Gate message-processing endpoints until startup completes
|
|
1073
|
+
const readinessGate = (req, res, next) => {
|
|
1074
|
+
if (_serverReady) return next();
|
|
1075
|
+
res.status(503).json({ error: "Server is starting up — loading skills, MCP, and channels. Please wait a moment and retry." });
|
|
1076
|
+
};
|
|
1077
|
+
app.use("/api/chat", readinessGate);
|
|
1078
|
+
app.post("/api/tasks", readinessGate);
|
|
1079
|
+
|
|
721
1080
|
// --- Start server ---
|
|
722
1081
|
app.listen(config.port, async () => {
|
|
723
1082
|
console.log("\n--- Daemora Server ---");
|
|
724
1083
|
console.log(`Running on http://localhost:${config.port}`);
|
|
725
1084
|
console.log(`Model: ${config.defaultModel}`);
|
|
1085
|
+
if (process.env.SUB_AGENT_MODEL) console.log(`Sub-agent model: ${process.env.SUB_AGENT_MODEL}`);
|
|
726
1086
|
console.log(`Permission tier: ${config.permissionTier}`);
|
|
727
|
-
console.log(`Tools loaded: ${Object.keys(toolFunctions).join(", ")}`);
|
|
728
|
-
console.log(`Total tools: ${Object.keys(toolFunctions).length}`);
|
|
729
1087
|
console.log(`Data dir: ${config.dataDir}`);
|
|
730
1088
|
console.log(`Daemon mode: ${config.daemonMode}`);
|
|
731
|
-
console.log(`Task runner: active (concurrency: 2)`);
|
|
732
1089
|
|
|
733
|
-
//
|
|
734
|
-
|
|
1090
|
+
// ── Phase 1: Load skills + embeddings (must complete before processing messages) ──
|
|
1091
|
+
console.log("[Startup] Loading skills...");
|
|
1092
|
+
skillLoader.load();
|
|
1093
|
+
console.log(`[Startup] Skills loaded: ${skillLoader.list().length}`);
|
|
1094
|
+
|
|
1095
|
+
console.log("[Startup] Initializing embeddings...");
|
|
1096
|
+
try {
|
|
1097
|
+
const { ensureOllamaEmbedModel } = await import("./utils/Embeddings.js");
|
|
1098
|
+
await ensureOllamaEmbedModel();
|
|
1099
|
+
} catch { /* non-fatal */ }
|
|
1100
|
+
|
|
1101
|
+
// Embed skills (uses whatever embedding provider is available)
|
|
1102
|
+
try {
|
|
1103
|
+
await skillLoader.embedSkills();
|
|
1104
|
+
console.log("[Startup] Skill embeddings ready");
|
|
1105
|
+
} catch { /* non-fatal — TF-IDF fallback always works */ }
|
|
735
1106
|
|
|
736
|
-
//
|
|
1107
|
+
// ── Phase 2: Connect MCP servers ──
|
|
1108
|
+
console.log("[Startup] Connecting MCP servers...");
|
|
1109
|
+
try {
|
|
1110
|
+
await mcpManager.init();
|
|
1111
|
+
} catch (e) {
|
|
1112
|
+
console.log(`[Startup] MCP init error (non-fatal): ${e.message}`);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ── Phase 3: Start channels ──
|
|
1116
|
+
console.log("[Startup] Starting channels...");
|
|
737
1117
|
try {
|
|
738
1118
|
await channelRegistry.startAll();
|
|
739
1119
|
} catch (e) {
|
|
740
|
-
console.log(`[
|
|
1120
|
+
console.log(`[Startup] Channel start error: ${e.message}`);
|
|
741
1121
|
}
|
|
742
|
-
|
|
1122
|
+
|
|
1123
|
+
// ── Ready — start processing messages ──
|
|
1124
|
+
taskRunner.start();
|
|
1125
|
+
console.log(`[Startup] Tools: ${Object.keys(toolFunctions).length}`);
|
|
1126
|
+
console.log(`[Startup] Task runner: active (concurrency: 2)`);
|
|
1127
|
+
_serverReady = true;
|
|
1128
|
+
console.log("[Startup] Server ready ✓\n");
|
|
743
1129
|
});
|
|
744
1130
|
|
|
745
1131
|
// Graceful shutdown
|
|
@@ -218,11 +218,12 @@ const _profileEnvMap = {
|
|
|
218
218
|
* 1. explicitModel (caller override)
|
|
219
219
|
* 2. Per-tenant modelRoutes[profile]
|
|
220
220
|
* 3. Global CODE_MODEL / RESEARCH_MODEL / WRITER_MODEL / ANALYST_MODEL env vars
|
|
221
|
-
* 4.
|
|
222
|
-
* 5.
|
|
221
|
+
* 4. SUB_AGENT_MODEL (global sub-agent default)
|
|
222
|
+
* 5. Per-tenant general model override
|
|
223
|
+
* 6. DEFAULT_MODEL env var / hardcoded default
|
|
223
224
|
*
|
|
224
225
|
* @param {string|null} profile - e.g. "coder", "researcher", "writer", "analyst"
|
|
225
|
-
* @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model)
|
|
226
|
+
* @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model, .subAgentModel)
|
|
226
227
|
* @param {string|null} explicitModel - Caller-supplied model override (highest priority)
|
|
227
228
|
* @returns {string} Resolved model ID
|
|
228
229
|
*/
|
|
@@ -230,6 +231,9 @@ export function resolveModelForProfile(profile, tenantConfig = {}, explicitModel
|
|
|
230
231
|
if (explicitModel) return explicitModel;
|
|
231
232
|
if (profile && tenantConfig.modelRoutes?.[profile]) return tenantConfig.modelRoutes[profile];
|
|
232
233
|
if (profile && process.env[_profileEnvMap[profile]]) return process.env[_profileEnvMap[profile]];
|
|
234
|
+
// Sub-agent model: tenant-level > env-level
|
|
235
|
+
if (tenantConfig.subAgentModel) return tenantConfig.subAgentModel;
|
|
236
|
+
if (process.env.SUB_AGENT_MODEL) return process.env.SUB_AGENT_MODEL;
|
|
233
237
|
if (tenantConfig.model) return tenantConfig.model;
|
|
234
238
|
return process.env.DEFAULT_MODEL || "openai:gpt-4.1-mini";
|
|
235
239
|
}
|