morpheus-cli 0.2.4 → 0.2.6
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/README.md +203 -0
- package/dist/channels/telegram.js +301 -2
- package/dist/cli/commands/doctor.js +62 -0
- package/dist/cli/commands/init.js +66 -11
- package/dist/cli/commands/restart.js +167 -0
- package/dist/cli/index.js +2 -0
- package/dist/config/manager.js +84 -5
- package/dist/config/mcp-manager.js +140 -0
- package/dist/config/precedence.js +138 -0
- package/dist/config/schemas.js +3 -1
- package/dist/http/__tests__/status_api.test.js +55 -0
- package/dist/http/__tests__/status_with_server_api.test.js +60 -0
- package/dist/http/api.js +85 -0
- package/dist/http/middleware/auth.js +7 -5
- package/dist/http/server.js +21 -0
- package/dist/runtime/__tests__/manual_start_verify.js +1 -0
- package/dist/runtime/oracle.js +3 -3
- package/dist/runtime/providers/factory.js +14 -4
- package/dist/runtime/tools/config-tools.js +25 -31
- package/dist/types/config.js +1 -0
- package/dist/ui/assets/index-BiXkm8Yr.css +1 -0
- package/dist/ui/assets/index-BrbyUtJ5.js +96 -0
- package/dist/ui/index.html +16 -14
- package/package.json +1 -1
- package/dist/ui/assets/index-3USYAgWN.css +0 -1
- package/dist/ui/assets/index-Pd9zlYEP.js +0 -58
|
@@ -30,6 +30,7 @@ export const initCommand = new Command('init')
|
|
|
30
30
|
choices: [
|
|
31
31
|
{ name: 'OpenAI', value: 'openai' },
|
|
32
32
|
{ name: 'Anthropic', value: 'anthropic' },
|
|
33
|
+
{ name: 'OpenRouter', value: 'openrouter' },
|
|
33
34
|
{ name: 'Ollama', value: 'ollama' },
|
|
34
35
|
{ name: 'Google Gemini', value: 'gemini' },
|
|
35
36
|
],
|
|
@@ -43,6 +44,9 @@ export const initCommand = new Command('init')
|
|
|
43
44
|
case 'anthropic':
|
|
44
45
|
defaultModel = 'claude-3-5-sonnet-20240620';
|
|
45
46
|
break;
|
|
47
|
+
case 'openrouter':
|
|
48
|
+
defaultModel = 'openrouter/auto';
|
|
49
|
+
break;
|
|
46
50
|
case 'ollama':
|
|
47
51
|
defaultModel = 'llama3';
|
|
48
52
|
break;
|
|
@@ -59,10 +63,23 @@ export const initCommand = new Command('init')
|
|
|
59
63
|
});
|
|
60
64
|
let apiKey;
|
|
61
65
|
const hasExistingKey = !!currentConfig.llm.api_key;
|
|
62
|
-
|
|
66
|
+
let apiKeyMessage = hasExistingKey
|
|
63
67
|
? 'Enter API Key (leave empty to preserve existing, or if using env vars):'
|
|
64
68
|
: 'Enter API Key (leave empty if using env vars):';
|
|
65
|
-
|
|
69
|
+
// Add info about environment variables to the message
|
|
70
|
+
if (provider === 'openai') {
|
|
71
|
+
apiKeyMessage = `${apiKeyMessage} (Env var: OPENAI_API_KEY)`;
|
|
72
|
+
}
|
|
73
|
+
else if (provider === 'anthropic') {
|
|
74
|
+
apiKeyMessage = `${apiKeyMessage} (Env var: ANTHROPIC_API_KEY)`;
|
|
75
|
+
}
|
|
76
|
+
else if (provider === 'gemini') {
|
|
77
|
+
apiKeyMessage = `${apiKeyMessage} (Env var: GOOGLE_API_KEY)`;
|
|
78
|
+
}
|
|
79
|
+
else if (provider === 'openrouter') {
|
|
80
|
+
apiKeyMessage = `${apiKeyMessage} (Env var: OPENROUTER_API_KEY)`;
|
|
81
|
+
}
|
|
82
|
+
if (provider !== 'ollama' && provider !== 'openrouter') {
|
|
66
83
|
apiKey = await password({
|
|
67
84
|
message: apiKeyMessage,
|
|
68
85
|
});
|
|
@@ -75,6 +92,14 @@ export const initCommand = new Command('init')
|
|
|
75
92
|
if (apiKey) {
|
|
76
93
|
await configManager.set('llm.api_key', apiKey);
|
|
77
94
|
}
|
|
95
|
+
// Base URL Configuration for OpenRouter
|
|
96
|
+
if (provider === 'openrouter') {
|
|
97
|
+
const baseUrl = await input({
|
|
98
|
+
message: 'Enter OpenRouter Base URL:',
|
|
99
|
+
default: currentConfig.llm.base_url || 'https://openrouter.ai/api/v1',
|
|
100
|
+
});
|
|
101
|
+
await configManager.set('llm.base_url', baseUrl);
|
|
102
|
+
}
|
|
78
103
|
// Context Window Configuration
|
|
79
104
|
const contextWindow = await input({
|
|
80
105
|
message: 'Context Window Size (number of messages to send to LLM):',
|
|
@@ -105,6 +130,7 @@ export const initCommand = new Command('init')
|
|
|
105
130
|
choices: [
|
|
106
131
|
{ name: 'OpenAI', value: 'openai' },
|
|
107
132
|
{ name: 'Anthropic', value: 'anthropic' },
|
|
133
|
+
{ name: 'OpenRouter', value: 'openrouter' },
|
|
108
134
|
{ name: 'Ollama', value: 'ollama' },
|
|
109
135
|
{ name: 'Google Gemini', value: 'gemini' },
|
|
110
136
|
],
|
|
@@ -118,6 +144,9 @@ export const initCommand = new Command('init')
|
|
|
118
144
|
case 'anthropic':
|
|
119
145
|
defaultSatiModel = 'claude-3-5-sonnet-20240620';
|
|
120
146
|
break;
|
|
147
|
+
case 'openrouter':
|
|
148
|
+
defaultSatiModel = 'openrouter/auto';
|
|
149
|
+
break;
|
|
121
150
|
case 'ollama':
|
|
122
151
|
defaultSatiModel = 'llama3';
|
|
123
152
|
break;
|
|
@@ -133,9 +162,22 @@ export const initCommand = new Command('init')
|
|
|
133
162
|
default: defaultSatiModel,
|
|
134
163
|
});
|
|
135
164
|
const hasExistingSatiKey = !!currentConfig.santi?.api_key;
|
|
136
|
-
|
|
137
|
-
? 'Enter Sati API Key (leave empty to preserve existing):'
|
|
138
|
-
: 'Enter Sati API Key:';
|
|
165
|
+
let santiKeyMsg = hasExistingSatiKey
|
|
166
|
+
? 'Enter Sati API Key (leave empty to preserve existing, or if using env vars):'
|
|
167
|
+
: 'Enter Sati API Key (leave empty if using env vars):';
|
|
168
|
+
// Add info about environment variables to the message
|
|
169
|
+
if (santiProvider === 'openai') {
|
|
170
|
+
santiKeyMsg = `${santiKeyMsg} (Env var: OPENAI_API_KEY)`;
|
|
171
|
+
}
|
|
172
|
+
else if (santiProvider === 'anthropic') {
|
|
173
|
+
santiKeyMsg = `${santiKeyMsg} (Env var: ANTHROPIC_API_KEY)`;
|
|
174
|
+
}
|
|
175
|
+
else if (santiProvider === 'gemini') {
|
|
176
|
+
santiKeyMsg = `${santiKeyMsg} (Env var: GOOGLE_API_KEY)`;
|
|
177
|
+
}
|
|
178
|
+
else if (santiProvider === 'openrouter') {
|
|
179
|
+
santiKeyMsg = `${santiKeyMsg} (Env var: OPENROUTER_API_KEY)`;
|
|
180
|
+
}
|
|
139
181
|
const keyInput = await password({ message: santiKeyMsg });
|
|
140
182
|
if (keyInput) {
|
|
141
183
|
santiApiKey = keyInput;
|
|
@@ -146,6 +188,14 @@ export const initCommand = new Command('init')
|
|
|
146
188
|
else {
|
|
147
189
|
santiApiKey = undefined; // Ensure we don't accidentally carry over invalid state
|
|
148
190
|
}
|
|
191
|
+
// Base URL Configuration for Sati OpenRouter
|
|
192
|
+
if (santiProvider === 'openrouter') {
|
|
193
|
+
const satiBaseUrl = await input({
|
|
194
|
+
message: 'Enter Sati OpenRouter Base URL:',
|
|
195
|
+
default: currentConfig.santi?.base_url || 'https://openrouter.ai/api/v1',
|
|
196
|
+
});
|
|
197
|
+
await configManager.set('santi.base_url', satiBaseUrl);
|
|
198
|
+
}
|
|
149
199
|
}
|
|
150
200
|
const memoryLimit = await input({
|
|
151
201
|
message: 'Sati Memory Retrieval Limit (messages):',
|
|
@@ -171,9 +221,11 @@ export const initCommand = new Command('init')
|
|
|
171
221
|
}
|
|
172
222
|
else {
|
|
173
223
|
const hasExistingAudioKey = !!currentConfig.audio?.apiKey;
|
|
174
|
-
|
|
175
|
-
? 'Enter Gemini API Key for Audio (leave empty to preserve existing):'
|
|
176
|
-
: 'Enter Gemini API Key for Audio:';
|
|
224
|
+
let audioKeyMessage = hasExistingAudioKey
|
|
225
|
+
? 'Enter Gemini API Key for Audio (leave empty to preserve existing, or if using env vars):'
|
|
226
|
+
: 'Enter Gemini API Key for Audio (leave empty if using env vars):';
|
|
227
|
+
// Add info about environment variables to the message
|
|
228
|
+
audioKeyMessage = `${audioKeyMessage} (Env var: GOOGLE_API_KEY)`;
|
|
177
229
|
audioKey = await password({
|
|
178
230
|
message: audioKeyMessage,
|
|
179
231
|
});
|
|
@@ -210,10 +262,13 @@ export const initCommand = new Command('init')
|
|
|
210
262
|
display.log(chalk.gray('1. Create a bot via @BotFather to get your token.'));
|
|
211
263
|
display.log(chalk.gray('2. Get your User ID via @userinfobot.\n'));
|
|
212
264
|
const hasExistingToken = !!currentConfig.channels.telegram?.token;
|
|
265
|
+
let telegramTokenMessage = hasExistingToken
|
|
266
|
+
? 'Enter Telegram Bot Token (leave empty to preserve existing, or if using env vars):'
|
|
267
|
+
: 'Enter Telegram Bot Token (leave empty if using env vars):';
|
|
268
|
+
// Add info about environment variables to the message
|
|
269
|
+
telegramTokenMessage = `${telegramTokenMessage} (Env var: TELEGRAM_BOT_TOKEN)`;
|
|
213
270
|
const token = await password({
|
|
214
|
-
message:
|
|
215
|
-
? 'Enter Telegram Bot Token (leave empty to preserve existing):'
|
|
216
|
-
: 'Enter Telegram Bot Token:',
|
|
271
|
+
message: telegramTokenMessage,
|
|
217
272
|
validate: (value) => {
|
|
218
273
|
if (value.length > 0)
|
|
219
274
|
return true;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { scaffold } from '../../runtime/scaffold.js';
|
|
5
|
+
import { DisplayManager } from '../../runtime/display.js';
|
|
6
|
+
import { writePid, readPid, isProcessRunning, clearPid, checkStalePid } from '../../runtime/lifecycle.js';
|
|
7
|
+
import { ConfigManager } from '../../config/manager.js';
|
|
8
|
+
import { renderBanner } from '../utils/render.js';
|
|
9
|
+
import { TelegramAdapter } from '../../channels/telegram.js';
|
|
10
|
+
import { PATHS } from '../../config/paths.js';
|
|
11
|
+
import { Oracle } from '../../runtime/oracle.js';
|
|
12
|
+
import { ProviderError } from '../../runtime/errors.js';
|
|
13
|
+
import { HttpServer } from '../../http/server.js';
|
|
14
|
+
import { getVersion } from '../utils/version.js';
|
|
15
|
+
export const restartCommand = new Command('restart')
|
|
16
|
+
.description('Restart the Morpheus agent')
|
|
17
|
+
.option('--ui', 'Enable web UI', true)
|
|
18
|
+
.option('--no-ui', 'Disable web UI')
|
|
19
|
+
.option('-p, --port <number>', 'Port for web UI', '3333')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
const display = DisplayManager.getInstance();
|
|
22
|
+
try {
|
|
23
|
+
// First, try to stop the current process
|
|
24
|
+
display.log(chalk.blue('Stopping current Morpheus process...'));
|
|
25
|
+
await checkStalePid();
|
|
26
|
+
const pid = await readPid();
|
|
27
|
+
if (pid) {
|
|
28
|
+
if (isProcessRunning(pid)) {
|
|
29
|
+
process.kill(pid, 'SIGTERM');
|
|
30
|
+
display.log(chalk.green(`Sent stop signal to Morpheus (PID: ${pid}).`));
|
|
31
|
+
// Wait a bit for the process to terminate
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
display.log(chalk.yellow('Current process not running, continuing with restart...'));
|
|
36
|
+
await clearPid();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
display.log(chalk.yellow('No running process found, continuing with restart...'));
|
|
41
|
+
}
|
|
42
|
+
// Now start a new process
|
|
43
|
+
display.log(chalk.blue('Starting new Morpheus process...'));
|
|
44
|
+
renderBanner(getVersion());
|
|
45
|
+
await scaffold(); // Ensure env exists
|
|
46
|
+
// Cleanup stale PID first
|
|
47
|
+
await checkStalePid();
|
|
48
|
+
const existingPid = await readPid();
|
|
49
|
+
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
50
|
+
display.log(chalk.red(`Morpheus is already running (PID: ${existingPid})`));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
// Check config existence
|
|
54
|
+
if (!await fs.pathExists(PATHS.config)) {
|
|
55
|
+
display.log(chalk.yellow("Configuration not found."));
|
|
56
|
+
display.log(chalk.cyan("Please run 'morpheus init' first to set up your agent."));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
// Write current PID
|
|
60
|
+
await writePid(process.pid);
|
|
61
|
+
const configManager = ConfigManager.getInstance();
|
|
62
|
+
const config = await configManager.load();
|
|
63
|
+
// Initialize persistent logging
|
|
64
|
+
await display.initialize(config.logging);
|
|
65
|
+
display.log(chalk.green(`Morpheus Agent (${config.agent.name}) starting...`));
|
|
66
|
+
display.log(chalk.gray(`PID: ${process.pid}`));
|
|
67
|
+
if (options.ui) {
|
|
68
|
+
display.log(chalk.blue(`Web UI enabled to port ${options.port}`));
|
|
69
|
+
}
|
|
70
|
+
// Initialize Oracle
|
|
71
|
+
const oracle = new Oracle(config);
|
|
72
|
+
try {
|
|
73
|
+
display.startSpinner(`Initializing ${config.llm.provider} oracle...`);
|
|
74
|
+
await oracle.initialize();
|
|
75
|
+
display.stopSpinner();
|
|
76
|
+
display.log(chalk.green('✓ Oracle initialized'), { source: 'Oracle' });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
display.stopSpinner();
|
|
80
|
+
if (err instanceof ProviderError) {
|
|
81
|
+
display.log(chalk.red(`\nProvider Error (${err.provider}):`));
|
|
82
|
+
display.log(chalk.white(err.message));
|
|
83
|
+
if (err.suggestion) {
|
|
84
|
+
display.log(chalk.yellow(`Tip: ${err.suggestion}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
display.log(chalk.red('\nOracle initialization failed:'));
|
|
89
|
+
display.log(chalk.white(err.message));
|
|
90
|
+
if (err.message.includes('API Key')) {
|
|
91
|
+
display.log(chalk.yellow('Tip: Check your API key in configuration or environment variables.'));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await clearPid();
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const adapters = [];
|
|
98
|
+
let httpServer;
|
|
99
|
+
// Initialize Web UI
|
|
100
|
+
if (options.ui && config.ui.enabled) {
|
|
101
|
+
try {
|
|
102
|
+
httpServer = new HttpServer();
|
|
103
|
+
// Use CLI port if provided and valid, otherwise fallback to config or default
|
|
104
|
+
const port = parseInt(options.port) || config.ui.port || 3333;
|
|
105
|
+
httpServer.start(port);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
display.log(chalk.red(`Failed to start Web UI: ${e.message}`));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Initialize Telegram
|
|
112
|
+
if (config.channels.telegram.enabled) {
|
|
113
|
+
if (config.channels.telegram.token) {
|
|
114
|
+
const telegram = new TelegramAdapter(oracle);
|
|
115
|
+
try {
|
|
116
|
+
await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
|
|
117
|
+
adapters.push(telegram);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
display.log(chalk.red('Failed to initialize Telegram adapter. Continuing...'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Handle graceful shutdown
|
|
128
|
+
const shutdown = async (signal) => {
|
|
129
|
+
display.stopSpinner();
|
|
130
|
+
display.log(`\n${signal} received. Shutting down...`);
|
|
131
|
+
if (httpServer) {
|
|
132
|
+
httpServer.stop();
|
|
133
|
+
}
|
|
134
|
+
for (const adapter of adapters) {
|
|
135
|
+
await adapter.disconnect();
|
|
136
|
+
}
|
|
137
|
+
await clearPid();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
};
|
|
140
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
141
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
142
|
+
// Allow ESC to exit
|
|
143
|
+
if (process.stdin.isTTY) {
|
|
144
|
+
process.stdin.setRawMode(true);
|
|
145
|
+
process.stdin.resume();
|
|
146
|
+
process.stdin.setEncoding('utf8');
|
|
147
|
+
process.stdin.on('data', (key) => {
|
|
148
|
+
// ESC or Ctrl+C
|
|
149
|
+
if (key === '\u001B' || key === '\u0003') {
|
|
150
|
+
shutdown('User Quit');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Keep process alive (Mock Agent Loop)
|
|
155
|
+
display.startSpinner('Agent active and listening... (Press ctrl+c to stop)');
|
|
156
|
+
// Prevent node from exiting
|
|
157
|
+
setInterval(() => {
|
|
158
|
+
// Heartbeat or background tasks would go here
|
|
159
|
+
}, 5000);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
display.stopSpinner();
|
|
163
|
+
console.error(chalk.red('Failed to restart Morpheus:'), error.message);
|
|
164
|
+
await clearPid();
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
});
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { statusCommand } from './commands/status.js';
|
|
|
5
5
|
import { configCommand } from './commands/config.js';
|
|
6
6
|
import { doctorCommand } from './commands/doctor.js';
|
|
7
7
|
import { initCommand } from './commands/init.js';
|
|
8
|
+
import { restartCommand } from './commands/restart.js';
|
|
8
9
|
import { scaffold } from '../runtime/scaffold.js';
|
|
9
10
|
import { getVersion } from './utils/version.js';
|
|
10
11
|
export async function cli() {
|
|
@@ -19,6 +20,7 @@ export async function cli() {
|
|
|
19
20
|
program.addCommand(initCommand);
|
|
20
21
|
program.addCommand(startCommand);
|
|
21
22
|
program.addCommand(stopCommand);
|
|
23
|
+
program.addCommand(restartCommand);
|
|
22
24
|
program.addCommand(statusCommand);
|
|
23
25
|
program.addCommand(configCommand);
|
|
24
26
|
program.addCommand(doctorCommand);
|
package/dist/config/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { PATHS } from './paths.js';
|
|
|
5
5
|
import { setByPath } from './utils.js';
|
|
6
6
|
import { ConfigSchema } from './schemas.js';
|
|
7
7
|
import { migrateConfigFile } from '../runtime/migration.js';
|
|
8
|
+
import { resolveApiKey, resolveModel, resolveNumeric, resolveString, resolveBoolean, resolveProvider, resolveStringArray } from './precedence.js';
|
|
8
9
|
export class ConfigManager {
|
|
9
10
|
static instance;
|
|
10
11
|
config = DEFAULT_CONFIG;
|
|
@@ -18,16 +19,15 @@ export class ConfigManager {
|
|
|
18
19
|
async load() {
|
|
19
20
|
try {
|
|
20
21
|
await migrateConfigFile();
|
|
22
|
+
let rawConfig = DEFAULT_CONFIG;
|
|
21
23
|
if (await fs.pathExists(PATHS.config)) {
|
|
22
24
|
const raw = await fs.readFile(PATHS.config, 'utf8');
|
|
23
25
|
const parsed = yaml.load(raw);
|
|
24
26
|
// Validate and merge with defaults via Zod
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
// File doesn't exist, use defaults
|
|
29
|
-
this.config = DEFAULT_CONFIG;
|
|
27
|
+
rawConfig = ConfigSchema.parse(parsed);
|
|
30
28
|
}
|
|
29
|
+
// Apply environment variable precedence to the loaded config
|
|
30
|
+
this.config = this.applyEnvironmentVariablePrecedence(rawConfig);
|
|
31
31
|
}
|
|
32
32
|
catch (error) {
|
|
33
33
|
console.error('Failed to load configuration:', error);
|
|
@@ -36,6 +36,85 @@ export class ConfigManager {
|
|
|
36
36
|
}
|
|
37
37
|
return this.config;
|
|
38
38
|
}
|
|
39
|
+
applyEnvironmentVariablePrecedence(config) {
|
|
40
|
+
// Apply precedence to agent config
|
|
41
|
+
const agentConfig = {
|
|
42
|
+
name: resolveString('MORPHEUS_AGENT_NAME', config.agent.name, DEFAULT_CONFIG.agent.name),
|
|
43
|
+
personality: resolveString('MORPHEUS_AGENT_PERSONALITY', config.agent.personality, DEFAULT_CONFIG.agent.personality)
|
|
44
|
+
};
|
|
45
|
+
// Apply precedence to LLM config
|
|
46
|
+
const llmProvider = resolveProvider('MORPHEUS_LLM_PROVIDER', config.llm.provider, DEFAULT_CONFIG.llm.provider);
|
|
47
|
+
const llmConfig = {
|
|
48
|
+
provider: llmProvider,
|
|
49
|
+
model: resolveModel(llmProvider, 'MORPHEUS_LLM_MODEL', config.llm.model),
|
|
50
|
+
temperature: resolveNumeric('MORPHEUS_LLM_TEMPERATURE', config.llm.temperature, DEFAULT_CONFIG.llm.temperature),
|
|
51
|
+
max_tokens: config.llm.max_tokens !== undefined ? resolveNumeric('MORPHEUS_LLM_MAX_TOKENS', config.llm.max_tokens, config.llm.max_tokens) : undefined,
|
|
52
|
+
api_key: resolveApiKey(llmProvider, 'MORPHEUS_LLM_API_KEY', config.llm.api_key),
|
|
53
|
+
base_url: config.llm.base_url, // base_url doesn't have environment variable precedence for now
|
|
54
|
+
context_window: config.llm.context_window !== undefined ? resolveNumeric('MORPHEUS_LLM_CONTEXT_WINDOW', config.llm.context_window, DEFAULT_CONFIG.llm.context_window) : undefined
|
|
55
|
+
};
|
|
56
|
+
// Apply precedence to Sati config
|
|
57
|
+
let santiConfig;
|
|
58
|
+
if (config.santi) {
|
|
59
|
+
const santiProvider = resolveProvider('MORPHEUS_SANTI_PROVIDER', config.santi.provider, llmConfig.provider);
|
|
60
|
+
santiConfig = {
|
|
61
|
+
provider: santiProvider,
|
|
62
|
+
model: resolveModel(santiProvider, 'MORPHEUS_SANTI_MODEL', config.santi.model || llmConfig.model),
|
|
63
|
+
temperature: resolveNumeric('MORPHEUS_SANTI_TEMPERATURE', config.santi.temperature, llmConfig.temperature),
|
|
64
|
+
max_tokens: config.santi.max_tokens !== undefined ? resolveNumeric('MORPHEUS_SANTI_MAX_TOKENS', config.santi.max_tokens, config.santi.max_tokens) : llmConfig.max_tokens,
|
|
65
|
+
api_key: resolveApiKey(santiProvider, 'MORPHEUS_SANTI_API_KEY', config.santi.api_key || llmConfig.api_key),
|
|
66
|
+
base_url: config.santi.base_url || config.llm.base_url,
|
|
67
|
+
context_window: config.santi.context_window !== undefined ? resolveNumeric('MORPHEUS_SANTI_CONTEXT_WINDOW', config.santi.context_window, config.santi.context_window) : llmConfig.context_window,
|
|
68
|
+
memory_limit: config.santi.memory_limit !== undefined ? resolveNumeric('MORPHEUS_SANTI_MEMORY_LIMIT', config.santi.memory_limit, config.santi.memory_limit) : undefined
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Apply precedence to audio config
|
|
72
|
+
const audioConfig = {
|
|
73
|
+
provider: config.audio.provider, // Audio provider is fixed as 'google'
|
|
74
|
+
model: resolveString('MORPHEUS_AUDIO_MODEL', config.audio.model, DEFAULT_CONFIG.audio.model),
|
|
75
|
+
enabled: resolveBoolean('MORPHEUS_AUDIO_ENABLED', config.audio.enabled, DEFAULT_CONFIG.audio.enabled),
|
|
76
|
+
apiKey: resolveApiKey('gemini', 'MORPHEUS_AUDIO_API_KEY', config.audio.apiKey),
|
|
77
|
+
maxDurationSeconds: resolveNumeric('MORPHEUS_AUDIO_MAX_DURATION', config.audio.maxDurationSeconds, DEFAULT_CONFIG.audio.maxDurationSeconds),
|
|
78
|
+
supportedMimeTypes: config.audio.supportedMimeTypes
|
|
79
|
+
};
|
|
80
|
+
// Apply precedence to channel configs
|
|
81
|
+
const channelsConfig = {
|
|
82
|
+
telegram: {
|
|
83
|
+
enabled: resolveBoolean('MORPHEUS_TELEGRAM_ENABLED', config.channels.telegram.enabled, DEFAULT_CONFIG.channels.telegram.enabled),
|
|
84
|
+
token: resolveString('MORPHEUS_TELEGRAM_TOKEN', config.channels.telegram.token, config.channels.telegram.token || ''),
|
|
85
|
+
allowedUsers: resolveStringArray('MORPHEUS_TELEGRAM_ALLOWED_USERS', config.channels.telegram.allowedUsers, DEFAULT_CONFIG.channels.telegram.allowedUsers)
|
|
86
|
+
},
|
|
87
|
+
discord: {
|
|
88
|
+
enabled: config.channels.discord.enabled, // Discord doesn't have env var precedence for now
|
|
89
|
+
token: config.channels.discord.token
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
// Apply precedence to UI config
|
|
93
|
+
const uiConfig = {
|
|
94
|
+
enabled: resolveBoolean('MORPHEUS_UI_ENABLED', config.ui.enabled, DEFAULT_CONFIG.ui.enabled),
|
|
95
|
+
port: resolveNumeric('MORPHEUS_UI_PORT', config.ui.port, DEFAULT_CONFIG.ui.port)
|
|
96
|
+
};
|
|
97
|
+
// Apply precedence to logging config
|
|
98
|
+
const loggingConfig = {
|
|
99
|
+
enabled: resolveBoolean('MORPHEUS_LOGGING_ENABLED', config.logging.enabled, DEFAULT_CONFIG.logging.enabled),
|
|
100
|
+
level: resolveString('MORPHEUS_LOGGING_LEVEL', config.logging.level, DEFAULT_CONFIG.logging.level),
|
|
101
|
+
retention: resolveString('MORPHEUS_LOGGING_RETENTION', config.logging.retention, DEFAULT_CONFIG.logging.retention)
|
|
102
|
+
};
|
|
103
|
+
// Memory config (deprecated, but keeping for backward compatibility)
|
|
104
|
+
const memoryConfig = {
|
|
105
|
+
limit: config.memory.limit // Not applying env var precedence to deprecated field
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
agent: agentConfig,
|
|
109
|
+
llm: llmConfig,
|
|
110
|
+
santi: santiConfig,
|
|
111
|
+
audio: audioConfig,
|
|
112
|
+
channels: channelsConfig,
|
|
113
|
+
ui: uiConfig,
|
|
114
|
+
logging: loggingConfig,
|
|
115
|
+
memory: memoryConfig
|
|
116
|
+
};
|
|
117
|
+
}
|
|
39
118
|
get() {
|
|
40
119
|
return this.config;
|
|
41
120
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { MCPConfigFileSchema, MCPServerConfigSchema } from './schemas.js';
|
|
4
|
+
import { DEFAULT_MCP_TEMPLATE } from '../types/mcp.js';
|
|
5
|
+
import { MORPHEUS_ROOT } from './paths.js';
|
|
6
|
+
const MCP_FILE_NAME = 'mcps.json';
|
|
7
|
+
const RESERVED_KEYS = new Set(['$schema']);
|
|
8
|
+
const readConfigFile = async () => {
|
|
9
|
+
const configPath = path.join(MORPHEUS_ROOT, MCP_FILE_NAME);
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
return MCPConfigFileSchema.parse(parsed);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT') {
|
|
17
|
+
return DEFAULT_MCP_TEMPLATE;
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const writeConfigFile = async (config) => {
|
|
23
|
+
const configPath = path.join(MORPHEUS_ROOT, MCP_FILE_NAME);
|
|
24
|
+
const serialized = JSON.stringify(config, null, 2) + '\n';
|
|
25
|
+
await fs.writeFile(configPath, serialized, 'utf-8');
|
|
26
|
+
};
|
|
27
|
+
const isMetadataKey = (key) => key.startsWith('_') || RESERVED_KEYS.has(key);
|
|
28
|
+
const normalizeName = (rawName) => rawName.replace(/^\$/, '');
|
|
29
|
+
const findRawKey = (config, name) => {
|
|
30
|
+
const direct = name in config ? name : null;
|
|
31
|
+
if (direct)
|
|
32
|
+
return direct;
|
|
33
|
+
const prefixed = `$${name}`;
|
|
34
|
+
if (prefixed in config)
|
|
35
|
+
return prefixed;
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
const ensureValidName = (name) => {
|
|
39
|
+
if (!name || name.trim().length === 0) {
|
|
40
|
+
throw new Error('Name is required.');
|
|
41
|
+
}
|
|
42
|
+
if (name.startsWith('_') || name === '$schema') {
|
|
43
|
+
throw new Error('Reserved names cannot be used for MCP servers.');
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export class MCPManager {
|
|
47
|
+
static async listServers() {
|
|
48
|
+
const config = await readConfigFile();
|
|
49
|
+
const servers = [];
|
|
50
|
+
for (const [rawName, value] of Object.entries(config)) {
|
|
51
|
+
if (isMetadataKey(rawName))
|
|
52
|
+
continue;
|
|
53
|
+
if (rawName === '$schema')
|
|
54
|
+
continue;
|
|
55
|
+
if (!value || typeof value !== 'object')
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = MCPServerConfigSchema.parse(value);
|
|
59
|
+
const enabled = !rawName.startsWith('$');
|
|
60
|
+
servers.push({
|
|
61
|
+
name: normalizeName(rawName),
|
|
62
|
+
enabled,
|
|
63
|
+
config: parsed,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return servers;
|
|
71
|
+
}
|
|
72
|
+
static async addServer(name, config) {
|
|
73
|
+
ensureValidName(name);
|
|
74
|
+
const parsedConfig = MCPServerConfigSchema.parse(config);
|
|
75
|
+
const file = await readConfigFile();
|
|
76
|
+
const existing = findRawKey(file, name);
|
|
77
|
+
if (existing) {
|
|
78
|
+
throw new Error(`Server "${name}" already exists.`);
|
|
79
|
+
}
|
|
80
|
+
const next = {};
|
|
81
|
+
for (const [key, value] of Object.entries(file)) {
|
|
82
|
+
next[key] = value;
|
|
83
|
+
}
|
|
84
|
+
next[name] = parsedConfig;
|
|
85
|
+
await writeConfigFile(next);
|
|
86
|
+
}
|
|
87
|
+
static async updateServer(name, config) {
|
|
88
|
+
ensureValidName(name);
|
|
89
|
+
const parsedConfig = MCPServerConfigSchema.parse(config);
|
|
90
|
+
const file = await readConfigFile();
|
|
91
|
+
const rawKey = findRawKey(file, name);
|
|
92
|
+
if (!rawKey) {
|
|
93
|
+
throw new Error(`Server "${name}" not found.`);
|
|
94
|
+
}
|
|
95
|
+
const next = {};
|
|
96
|
+
for (const [key, value] of Object.entries(file)) {
|
|
97
|
+
if (key === rawKey) {
|
|
98
|
+
next[key] = parsedConfig;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
next[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await writeConfigFile(next);
|
|
105
|
+
}
|
|
106
|
+
static async deleteServer(name) {
|
|
107
|
+
ensureValidName(name);
|
|
108
|
+
const file = await readConfigFile();
|
|
109
|
+
const rawKey = findRawKey(file, name);
|
|
110
|
+
if (!rawKey) {
|
|
111
|
+
throw new Error(`Server "${name}" not found.`);
|
|
112
|
+
}
|
|
113
|
+
const next = {};
|
|
114
|
+
for (const [key, value] of Object.entries(file)) {
|
|
115
|
+
if (key === rawKey)
|
|
116
|
+
continue;
|
|
117
|
+
next[key] = value;
|
|
118
|
+
}
|
|
119
|
+
await writeConfigFile(next);
|
|
120
|
+
}
|
|
121
|
+
static async setServerEnabled(name, enabled) {
|
|
122
|
+
ensureValidName(name);
|
|
123
|
+
const file = await readConfigFile();
|
|
124
|
+
const rawKey = findRawKey(file, name);
|
|
125
|
+
if (!rawKey) {
|
|
126
|
+
throw new Error(`Server "${name}" not found.`);
|
|
127
|
+
}
|
|
128
|
+
const targetKey = enabled ? normalizeName(rawKey) : `$${normalizeName(rawKey)}`;
|
|
129
|
+
const next = {};
|
|
130
|
+
for (const [key, value] of Object.entries(file)) {
|
|
131
|
+
if (key === rawKey) {
|
|
132
|
+
next[targetKey] = value;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
next[key] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await writeConfigFile(next);
|
|
139
|
+
}
|
|
140
|
+
}
|