morpheus-cli 0.6.4 → 0.6.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 CHANGED
@@ -82,8 +82,11 @@ Minimal `env.docker`:
82
82
  ```env
83
83
  OPENAI_API_KEY=sk-...
84
84
  THE_ARCHITECT_PASS=changeme
85
+ MORPHEUS_SECRET=<generate-a-random-secret>
85
86
  ```
86
87
 
88
+ > **Tip:** Generate a secure `MORPHEUS_SECRET` with: `openssl rand -base64 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
89
+
87
90
  **2. Run:**
88
91
 
89
92
  ```bash
@@ -121,6 +124,7 @@ docker run -d \
121
124
  -p 3333:3333 \
122
125
  -e OPENAI_API_KEY=sk-... \
123
126
  -e THE_ARCHITECT_PASS=changeme \
127
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
124
128
  -v morpheus_data:/root/.morpheus \
125
129
  morpheus
126
130
  ```
@@ -133,6 +137,7 @@ docker run -d \
133
137
  -p 3333:3333 \
134
138
  -e OPENAI_API_KEY=sk-... \
135
139
  -e THE_ARCHITECT_PASS=changeme \
140
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
136
141
  -e MORPHEUS_TELEGRAM_ENABLED=true \
137
142
  -e MORPHEUS_TELEGRAM_TOKEN=<bot-token> \
138
143
  -e MORPHEUS_TELEGRAM_ALLOWED_USERS=123456789 \
@@ -148,6 +153,7 @@ docker run -d \
148
153
  -p 3333:3333 \
149
154
  -e OPENAI_API_KEY=sk-... \
150
155
  -e THE_ARCHITECT_PASS=changeme \
156
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
151
157
  -e MORPHEUS_DISCORD_ENABLED=true \
152
158
  -e MORPHEUS_DISCORD_TOKEN=<bot-token> \
153
159
  -e MORPHEUS_DISCORD_ALLOWED_USERS=987654321 \
@@ -365,7 +371,7 @@ Provider-specific keys:
365
371
  - `THE_ARCHITECT_PASS`
366
372
 
367
373
  Security:
368
- - `MORPHEUS_SECRET` — AES-256-GCM key for encrypting Trinity database passwords (required when using Trinity)
374
+ - `MORPHEUS_SECRET` — AES-256-GCM encryption key for Trinity database passwords and agent API keys. When set, all API keys saved via UI or config file are automatically encrypted at rest.
369
375
 
370
376
  Generic Morpheus overrides (selected):
371
377
 
@@ -7,6 +7,7 @@ import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
7
7
  import { DisplayManager } from '../runtime/display.js';
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { createTelephonist } from '../runtime/telephonist.js';
10
+ import { getUsableApiKey } from '../runtime/trinity-crypto.js';
10
11
  // ─── Slash Command Definitions ────────────────────────────────────────────────
11
12
  const SLASH_COMMANDS = [
12
13
  new SlashCommandBuilder()
@@ -219,8 +220,8 @@ export class DiscordAdapter {
219
220
  await channel.send('Audio transcription is currently disabled.');
220
221
  return;
221
222
  }
222
- const apiKey = config.audio.apiKey ||
223
- (config.llm.provider === config.audio.provider ? config.llm.api_key : undefined);
223
+ const apiKey = getUsableApiKey(config.audio.apiKey) ||
224
+ (config.llm.provider === config.audio.provider ? getUsableApiKey(config.llm.api_key) : undefined);
224
225
  if (!apiKey) {
225
226
  this.display.log(`Audio transcription failed: No API key for provider '${config.audio.provider}'`, { source: 'Telephonist', level: 'error' });
226
227
  await channel.send(`Audio transcription requires an API key for provider '${config.audio.provider}'.`);
@@ -8,6 +8,7 @@ import { spawn } from 'child_process';
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { DisplayManager } from '../runtime/display.js';
10
10
  import { createTelephonist } from '../runtime/telephonist.js';
11
+ import { getUsableApiKey } from '../runtime/trinity-crypto.js';
11
12
  import { readPid, isProcessRunning, checkStalePid } from '../runtime/lifecycle.js';
12
13
  import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
13
14
  import { SatiRepository } from '../runtime/memory/sati/repository.js';
@@ -281,8 +282,8 @@ export class TelegramAdapter {
281
282
  await ctx.reply("Audio transcription is currently disabled.");
282
283
  return;
283
284
  }
284
- const apiKey = config.audio.apiKey ||
285
- (config.llm.provider === config.audio.provider ? config.llm.api_key : undefined);
285
+ const apiKey = getUsableApiKey(config.audio.apiKey) ||
286
+ (config.llm.provider === config.audio.provider ? getUsableApiKey(config.llm.api_key) : undefined);
286
287
  if (!apiKey) {
287
288
  this.display.log(`Audio transcription failed: No API key available for provider '${config.audio.provider}'`, { source: 'Telephonist', level: 'error' });
288
289
  await ctx.reply(`Audio transcription requires an API key for provider '${config.audio.provider}'. Please configure \`audio.apiKey\` or use the same provider as your LLM.`);
@@ -811,8 +812,9 @@ export class TelegramAdapter {
811
812
  return;
812
813
  }
813
814
  try {
815
+ const globalTz = this.config.getChronosConfig().timezone;
814
816
  const { parse: chronoParse } = await import('chrono-node');
815
- const results = chronoParse(fullText);
817
+ const results = chronoParse(fullText, { timezone: globalTz });
816
818
  if (!results.length) {
817
819
  await ctx.reply('Could not detect a time expression. Try: `/chronos Check disk space tomorrow at 9am`', { parse_mode: 'Markdown' });
818
820
  return;
@@ -821,7 +823,6 @@ export class TelegramAdapter {
821
823
  const match = results[0];
822
824
  const matchedText = fullText.slice(match.index, match.index + match.text.length);
823
825
  const prompt = (fullText.slice(0, match.index) + fullText.slice(match.index + match.text.length)).replace(/\s+/g, ' ').trim() || fullText;
824
- const globalTz = this.config.getChronosConfig().timezone;
825
826
  const { parseScheduleExpression } = await import('../runtime/chronos/parser.js');
826
827
  const schedule = parseScheduleExpression(matchedText, 'once', { timezone: globalTz });
827
828
  const formatted = new Date(schedule.next_run_at).toLocaleString('en-US', {
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
+ import path from 'path';
4
5
  import { confirm } from '@inquirer/prompts';
5
6
  import { scaffold } from '../../runtime/scaffold.js';
6
7
  import { DisplayManager } from '../../runtime/display.js';
@@ -21,6 +22,35 @@ import { TaskWorker } from '../../runtime/tasks/worker.js';
21
22
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
22
23
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
23
24
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
25
+ // Load .env file explicitly in start command
26
+ const envPath = path.join(process.cwd(), '.env');
27
+ if (fs.existsSync(envPath)) {
28
+ try {
29
+ const envConfig = fs.readFileSync(envPath, 'utf-8');
30
+ envConfig.split('\n').forEach(line => {
31
+ const trimmed = line.trim();
32
+ if (!trimmed || trimmed.startsWith('#'))
33
+ return;
34
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
35
+ if (match) {
36
+ const key = match[1].trim();
37
+ let value = match[2].trim();
38
+ // Remove quotes if present
39
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ // Don't overwrite existing env vars
43
+ if (!process.env[key]) {
44
+ process.env[key] = value;
45
+ }
46
+ }
47
+ });
48
+ console.log('[DEBUG] Loaded .env file from:', envPath);
49
+ }
50
+ catch (err) {
51
+ console.error('[WARN] Failed to load .env:', err);
52
+ }
53
+ }
24
54
  export const startCommand = new Command('start')
25
55
  .description('Start the Morpheus agent')
26
56
  .option('--ui', 'Enable web UI', true)
@@ -6,6 +6,8 @@ import { setByPath } from './utils.js';
6
6
  import { ConfigSchema } from './schemas.js';
7
7
  import { migrateConfigFile } from '../runtime/migration.js';
8
8
  import { resolveApiKey, resolveModel, resolveNumeric, resolveString, resolveBoolean, resolveProvider, resolveStringArray } from './precedence.js';
9
+ import { encrypt, safeDecrypt, looksLikeEncrypted, canEncrypt } from '../runtime/trinity-crypto.js';
10
+ import { DisplayManager } from '../runtime/display.js';
9
11
  export class ConfigManager {
10
12
  static instance;
11
13
  config = DEFAULT_CONFIG;
@@ -16,6 +18,89 @@ export class ConfigManager {
16
18
  }
17
19
  return ConfigManager.instance;
18
20
  }
21
+ /**
22
+ * Decrypts API keys in config if they appear to be encrypted.
23
+ * Fail-open: returns original value if decryption fails.
24
+ */
25
+ decryptAgentApiKeys(config) {
26
+ const decrypted = { ...config };
27
+ // Helper to decrypt a single key
28
+ const tryDecrypt = (apiKey) => {
29
+ if (!apiKey)
30
+ return undefined;
31
+ if (!looksLikeEncrypted(apiKey))
32
+ return apiKey;
33
+ const decrypted = safeDecrypt(apiKey);
34
+ return decrypted ?? apiKey; // Return original if decryption fails
35
+ };
36
+ // Decrypt Oracle/LLM
37
+ if (decrypted.llm.api_key) {
38
+ decrypted.llm = { ...decrypted.llm, api_key: tryDecrypt(decrypted.llm.api_key) };
39
+ }
40
+ // Decrypt Sati
41
+ if (decrypted.sati?.api_key) {
42
+ decrypted.sati = { ...decrypted.sati, api_key: tryDecrypt(decrypted.sati.api_key) };
43
+ }
44
+ // Decrypt Apoc
45
+ if (decrypted.apoc?.api_key) {
46
+ decrypted.apoc = { ...decrypted.apoc, api_key: tryDecrypt(decrypted.apoc.api_key) };
47
+ }
48
+ // Decrypt Neo
49
+ if (decrypted.neo?.api_key) {
50
+ decrypted.neo = { ...decrypted.neo, api_key: tryDecrypt(decrypted.neo.api_key) };
51
+ }
52
+ // Decrypt Trinity
53
+ if (decrypted.trinity?.api_key) {
54
+ decrypted.trinity = { ...decrypted.trinity, api_key: tryDecrypt(decrypted.trinity.api_key) };
55
+ }
56
+ // Decrypt Audio (Telephonist)
57
+ if (decrypted.audio?.apiKey) {
58
+ decrypted.audio = { ...decrypted.audio, apiKey: tryDecrypt(decrypted.audio.apiKey) };
59
+ }
60
+ return decrypted;
61
+ }
62
+ /**
63
+ * Encrypts API keys in config if MORPHEUS_SECRET is set.
64
+ */
65
+ encryptAgentApiKeys(config) {
66
+ if (!canEncrypt())
67
+ return config;
68
+ const encrypted = { ...config };
69
+ // Helper to encrypt a single key
70
+ const tryEncrypt = (apiKey) => {
71
+ if (!apiKey)
72
+ return undefined;
73
+ // Don't double-encrypt
74
+ if (looksLikeEncrypted(apiKey))
75
+ return apiKey;
76
+ return encrypt(apiKey);
77
+ };
78
+ // Encrypt Oracle/LLM
79
+ if (encrypted.llm.api_key) {
80
+ encrypted.llm = { ...encrypted.llm, api_key: tryEncrypt(encrypted.llm.api_key) };
81
+ }
82
+ // Encrypt Sati
83
+ if (encrypted.sati?.api_key) {
84
+ encrypted.sati = { ...encrypted.sati, api_key: tryEncrypt(encrypted.sati.api_key) };
85
+ }
86
+ // Encrypt Apoc
87
+ if (encrypted.apoc?.api_key) {
88
+ encrypted.apoc = { ...encrypted.apoc, api_key: tryEncrypt(encrypted.apoc.api_key) };
89
+ }
90
+ // Encrypt Neo
91
+ if (encrypted.neo?.api_key) {
92
+ encrypted.neo = { ...encrypted.neo, api_key: tryEncrypt(encrypted.neo.api_key) };
93
+ }
94
+ // Encrypt Trinity
95
+ if (encrypted.trinity?.api_key) {
96
+ encrypted.trinity = { ...encrypted.trinity, api_key: tryEncrypt(encrypted.trinity.api_key) };
97
+ }
98
+ // Encrypt Audio (Telephonist)
99
+ if (encrypted.audio?.apiKey) {
100
+ encrypted.audio = { ...encrypted.audio, apiKey: tryEncrypt(encrypted.audio.apiKey) };
101
+ }
102
+ return encrypted;
103
+ }
19
104
  async load() {
20
105
  try {
21
106
  await migrateConfigFile();
@@ -222,9 +307,16 @@ export class ConfigManager {
222
307
  }
223
308
  async save(newConfig) {
224
309
  // Deep merge or overwrite? simpler to overwrite for now or merge top level
225
- const updated = { ...this.config, ...newConfig };
310
+ let updated = { ...this.config, ...newConfig };
311
+ // Encrypt API keys before saving if MORPHEUS_SECRET is set
312
+ updated = this.encryptAgentApiKeys(updated);
226
313
  // Validate before saving
227
314
  const valid = ConfigSchema.parse(updated);
315
+ // Warn if saving without encryption
316
+ if (!canEncrypt()) {
317
+ const display = DisplayManager.getInstance();
318
+ display.log('API keys saved in PLAINTEXT. Set MORPHEUS_SECRET environment variable to enable AES-256-GCM encryption.', { source: 'ConfigManager', level: 'warning' });
319
+ }
228
320
  await fs.ensureDir(PATHS.root);
229
321
  await fs.writeFile(PATHS.config, yaml.dump(valid), 'utf8');
230
322
  this.config = valid;
@@ -285,4 +377,28 @@ export class ConfigManager {
285
377
  }
286
378
  return defaults;
287
379
  }
380
+ /**
381
+ * Returns encryption status for all agent API keys.
382
+ */
383
+ getEncryptionStatus() {
384
+ const checkKey = (apiKey) => {
385
+ if (!apiKey)
386
+ return true; // No key = not plaintext
387
+ return looksLikeEncrypted(apiKey);
388
+ };
389
+ const apiKeysEncrypted = {
390
+ oracle: checkKey(this.config.llm.api_key),
391
+ sati: checkKey(this.config.sati?.api_key),
392
+ neo: checkKey(this.config.neo?.api_key),
393
+ apoc: checkKey(this.config.apoc?.api_key),
394
+ trinity: checkKey(this.config.trinity?.api_key),
395
+ audio: checkKey(this.config.audio?.apiKey),
396
+ };
397
+ const hasPlaintextKeys = Object.values(apiKeysEncrypted).some(encrypted => !encrypted);
398
+ return {
399
+ morpheusSecretSet: canEncrypt(),
400
+ apiKeysEncrypted,
401
+ hasPlaintextKeys,
402
+ };
403
+ }
288
404
  }
@@ -136,3 +136,110 @@ export function resolveProvider(genericEnvVar, configFileValue, defaultValue) {
136
136
  }
137
137
  return defaultValue;
138
138
  }
139
+ /**
140
+ * Check if a configuration value is being overridden by an environment variable.
141
+ * Returns true if the value comes from an environment variable (either provider-specific or generic).
142
+ */
143
+ export function isOverriddenByEnv(provider, genericEnvVar, configFileValue) {
144
+ const providerSpecificVars = {
145
+ 'openai': 'OPENAI_API_KEY',
146
+ 'anthropic': 'ANTHROPIC_API_KEY',
147
+ 'openrouter': 'OPENROUTER_API_KEY',
148
+ 'ollama': '',
149
+ 'gemini': 'GOOGLE_API_KEY'
150
+ };
151
+ const providerSpecificVar = providerSpecificVars[provider];
152
+ // Check if provider-specific variable is set
153
+ if (providerSpecificVar && process.env[providerSpecificVar]) {
154
+ return true;
155
+ }
156
+ // Check if generic variable is set
157
+ if (process.env[genericEnvVar]) {
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ /**
163
+ * Check if a generic configuration value is being overridden by an environment variable.
164
+ */
165
+ export function isEnvVarSet(envVarName) {
166
+ return process.env[envVarName] !== undefined && process.env[envVarName] !== '';
167
+ }
168
+ /**
169
+ * Get list of all active environment variable overrides for agent configurations.
170
+ */
171
+ export function getActiveEnvOverrides() {
172
+ const overrides = {};
173
+ // LLM/Oracle
174
+ if (isEnvVarSet('MORPHEUS_LLM_PROVIDER'))
175
+ overrides['llm.provider'] = true;
176
+ if (isEnvVarSet('MORPHEUS_LLM_MODEL'))
177
+ overrides['llm.model'] = true;
178
+ if (isEnvVarSet('MORPHEUS_LLM_TEMPERATURE'))
179
+ overrides['llm.temperature'] = true;
180
+ if (isEnvVarSet('MORPHEUS_LLM_MAX_TOKENS'))
181
+ overrides['llm.max_tokens'] = true;
182
+ if (isEnvVarSet('MORPHEUS_LLM_API_KEY'))
183
+ overrides['llm.api_key'] = true;
184
+ if (isEnvVarSet('MORPHEUS_LLM_CONTEXT_WINDOW'))
185
+ overrides['llm.context_window'] = true;
186
+ // Provider-specific API keys
187
+ if (isEnvVarSet('OPENAI_API_KEY'))
188
+ overrides['llm.api_key_openai'] = true;
189
+ if (isEnvVarSet('ANTHROPIC_API_KEY'))
190
+ overrides['llm.api_key_anthropic'] = true;
191
+ if (isEnvVarSet('GOOGLE_API_KEY'))
192
+ overrides['llm.api_key_gemini'] = true;
193
+ if (isEnvVarSet('OPENROUTER_API_KEY'))
194
+ overrides['llm.api_key_openrouter'] = true;
195
+ // Sati
196
+ if (isEnvVarSet('MORPHEUS_SATI_PROVIDER'))
197
+ overrides['sati.provider'] = true;
198
+ if (isEnvVarSet('MORPHEUS_SATI_MODEL'))
199
+ overrides['sati.model'] = true;
200
+ if (isEnvVarSet('MORPHEUS_SATI_TEMPERATURE'))
201
+ overrides['sati.temperature'] = true;
202
+ if (isEnvVarSet('MORPHEUS_SATI_API_KEY'))
203
+ overrides['sati.api_key'] = true;
204
+ // Neo
205
+ if (isEnvVarSet('MORPHEUS_NEO_PROVIDER'))
206
+ overrides['neo.provider'] = true;
207
+ if (isEnvVarSet('MORPHEUS_NEO_MODEL'))
208
+ overrides['neo.model'] = true;
209
+ if (isEnvVarSet('MORPHEUS_NEO_TEMPERATURE'))
210
+ overrides['neo.temperature'] = true;
211
+ if (isEnvVarSet('MORPHEUS_NEO_API_KEY'))
212
+ overrides['neo.api_key'] = true;
213
+ // Apoc
214
+ if (isEnvVarSet('MORPHEUS_APOC_PROVIDER'))
215
+ overrides['apoc.provider'] = true;
216
+ if (isEnvVarSet('MORPHEUS_APOC_MODEL'))
217
+ overrides['apoc.model'] = true;
218
+ if (isEnvVarSet('MORPHEUS_APOC_TEMPERATURE'))
219
+ overrides['apoc.temperature'] = true;
220
+ if (isEnvVarSet('MORPHEUS_APOC_API_KEY'))
221
+ overrides['apoc.api_key'] = true;
222
+ if (isEnvVarSet('MORPHEUS_APOC_WORKING_DIR'))
223
+ overrides['apoc.working_dir'] = true;
224
+ if (isEnvVarSet('MORPHEUS_APOC_TIMEOUT_MS'))
225
+ overrides['apoc.timeout_ms'] = true;
226
+ // Trinity
227
+ if (isEnvVarSet('MORPHEUS_TRINITY_PROVIDER'))
228
+ overrides['trinity.provider'] = true;
229
+ if (isEnvVarSet('MORPHEUS_TRINITY_MODEL'))
230
+ overrides['trinity.model'] = true;
231
+ if (isEnvVarSet('MORPHEUS_TRINITY_TEMPERATURE'))
232
+ overrides['trinity.temperature'] = true;
233
+ if (isEnvVarSet('MORPHEUS_TRINITY_API_KEY'))
234
+ overrides['trinity.api_key'] = true;
235
+ // Audio
236
+ if (isEnvVarSet('MORPHEUS_AUDIO_PROVIDER'))
237
+ overrides['audio.provider'] = true;
238
+ if (isEnvVarSet('MORPHEUS_AUDIO_MODEL'))
239
+ overrides['audio.model'] = true;
240
+ if (isEnvVarSet('MORPHEUS_AUDIO_API_KEY'))
241
+ overrides['audio.apiKey'] = true;
242
+ if (isEnvVarSet('MORPHEUS_AUDIO_MAX_DURATION'))
243
+ overrides['audio.maxDurationSeconds'] = true;
244
+ return overrides;
245
+ }
package/dist/http/api.js CHANGED
@@ -18,6 +18,7 @@ import { Trinity } from '../runtime/trinity.js';
18
18
  import { ChronosRepository } from '../runtime/chronos/repository.js';
19
19
  import { ChronosWorker } from '../runtime/chronos/worker.js';
20
20
  import { createChronosJobRouter, createChronosConfigRouter } from './routers/chronos.js';
21
+ import { getActiveEnvOverrides } from '../config/precedence.js';
21
22
  async function readLastLines(filePath, n) {
22
23
  try {
23
24
  const content = await fs.readFile(filePath, 'utf8');
@@ -813,6 +814,34 @@ export function createApiRouter(oracle, chronosWorker) {
813
814
  res.status(500).json({ error: error.message });
814
815
  }
815
816
  });
817
+ // ─── Encryption Status ─────────────────────────────────────────────────────
818
+ router.get('/config/encryption-status', (req, res) => {
819
+ try {
820
+ const status = configManager.getEncryptionStatus();
821
+ res.json(status);
822
+ }
823
+ catch (error) {
824
+ res.status(500).json({ error: error.message });
825
+ }
826
+ });
827
+ // ─── Environment Variable Overrides ────────────────────────────────────────
828
+ router.get('/config/env-overrides', (req, res) => {
829
+ try {
830
+ const overrides = getActiveEnvOverrides();
831
+ // Debug log to see what's being detected
832
+ // console.log('[DEBUG] Env overrides:', JSON.stringify(overrides, null, 2));
833
+ // console.log('[DEBUG] Sample env vars:', {
834
+ // MORPHEUS_LLM_PROVIDER: !!process.env.MORPHEUS_LLM_PROVIDER,
835
+ // MORPHEUS_LLM_MODEL: !!process.env.MORPHEUS_LLM_MODEL,
836
+ // OPENAI_API_KEY: !!process.env.OPENAI_API_KEY,
837
+ // });
838
+ res.json(overrides);
839
+ }
840
+ catch (error) {
841
+ console.error('[ERROR] Failed to get env overrides:', error);
842
+ res.status(500).json({ error: error.message });
843
+ }
844
+ });
816
845
  // ─── Trinity Databases CRUD ─────────────────────────────────────────────────
817
846
  const DatabaseCreateSchema = z.object({
818
847
  name: z.string().min(1).max(100),
@@ -77,6 +77,64 @@ function intervalToCron(expression) {
77
77
  `Supported formats: "every N minutes/hours/days/weeks", "every minute/hour/day/week", ` +
78
78
  `"every monday [at 9am]", "every monday and friday at 18:30", "every weekday", "every weekend", "daily", "weekly".`);
79
79
  }
80
+ /**
81
+ * Parses Portuguese time expressions and converts to ISO 8601 format.
82
+ * Handles patterns like "às 15h", "hoje às 15:30", "amanhã às 9h".
83
+ */
84
+ function parsePortugueseTimeExpression(expression, refDate, timezone) {
85
+ const lower = expression.toLowerCase().trim();
86
+ // Pattern: "às 15h", "as 15h", "às 15:30", "as 15:30"
87
+ const timeOnlyMatch = lower.match(/^(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?$/);
88
+ if (timeOnlyMatch) {
89
+ let hour = parseInt(timeOnlyMatch[1], 10);
90
+ const minute = timeOnlyMatch[2] ? parseInt(timeOnlyMatch[2], 10) : 0;
91
+ // Create date by setting hours in the target timezone
92
+ // We use Intl.DateTimeFormat to properly handle timezone
93
+ const targetDate = new Date();
94
+ const tzDate = new Date(targetDate.toLocaleString('en-US', { timeZone: timezone }));
95
+ tzDate.setHours(hour, minute, 0, 0);
96
+ // If time is in the past today, schedule for tomorrow
97
+ if (tzDate.getTime() <= refDate.getTime()) {
98
+ tzDate.setDate(tzDate.getDate() + 1);
99
+ }
100
+ return tzDate;
101
+ }
102
+ // Pattern: "hoje às 15h", "hoje as 15:30"
103
+ const todayMatch = lower.match(/^hoje\s+(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?$/);
104
+ if (todayMatch) {
105
+ let hour = parseInt(todayMatch[1], 10);
106
+ const minute = todayMatch[2] ? parseInt(todayMatch[2], 10) : 0;
107
+ const tzDate = new Date(new Date().toLocaleString('en-US', { timeZone: timezone }));
108
+ tzDate.setHours(hour, minute, 0, 0);
109
+ // If already passed, return null (can't schedule in the past)
110
+ if (tzDate.getTime() <= refDate.getTime()) {
111
+ return null;
112
+ }
113
+ return tzDate;
114
+ }
115
+ // Pattern: "amanhã às 15h", "amanha as 15:30", "amanhã às 15h da tarde"
116
+ const tomorrowMatch = lower.match(/^amanhã(?:ã)?\s+(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?(?:\s+(?:da|do)\s+(?:manhã|tarde|noite))?$/);
117
+ if (tomorrowMatch) {
118
+ let hour = parseInt(tomorrowMatch[1], 10);
119
+ const minute = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
120
+ const tzDate = new Date(new Date().toLocaleString('en-US', { timeZone: timezone }));
121
+ tzDate.setDate(tzDate.getDate() + 1);
122
+ tzDate.setHours(hour, minute, 0, 0);
123
+ return tzDate;
124
+ }
125
+ // Pattern: "daqui a X minutos/horas/dias"
126
+ const relativeMatch = lower.match(/^daqui\s+a\s+(\d+)\s+(minutos?|horas?|dias?|semanas?)$/);
127
+ if (relativeMatch) {
128
+ const amount = parseInt(relativeMatch[1], 10);
129
+ const unit = relativeMatch[2];
130
+ const ms = unit.startsWith('min') ? amount * 60_000
131
+ : unit.startsWith('hor') ? amount * 3_600_000
132
+ : unit.startsWith('dia') ? amount * 86_400_000
133
+ : amount * 7 * 86_400_000;
134
+ return new Date(refDate.getTime() + ms);
135
+ }
136
+ return null;
137
+ }
80
138
  function formatDatetime(date, timezone) {
81
139
  try {
82
140
  return date.toLocaleString('en-US', {
@@ -110,13 +168,17 @@ export function parseScheduleExpression(expression, type, opts = {}) {
110
168
  : amount * 7 * 86_400_000;
111
169
  parsed = new Date(refDate.getTime() + ms);
112
170
  }
113
- // 2. ISO 8601
171
+ // 2. Portuguese time expressions: "às 15h", "hoje às 15:30", "amanhã às 9h", "daqui a 30 minutos"
172
+ if (!parsed) {
173
+ parsed = parsePortugueseTimeExpression(expression, refDate, timezone);
174
+ }
175
+ // 3. ISO 8601
114
176
  if (!parsed) {
115
177
  const isoDate = new Date(expression);
116
178
  if (!isNaN(isoDate.getTime()))
117
179
  parsed = isoDate;
118
180
  }
119
- // 3. chrono-node NLP fallback ("tomorrow at 9am", "next friday", etc.)
181
+ // 4. chrono-node NLP fallback ("tomorrow at 9am", "next friday", etc.)
120
182
  if (!parsed) {
121
183
  const results = chrono.parse(expression, { instant: refDate, timezone });
122
184
  if (results.length > 0 && results[0].date()) {
@@ -125,7 +187,8 @@ export function parseScheduleExpression(expression, type, opts = {}) {
125
187
  }
126
188
  if (!parsed) {
127
189
  throw new Error(`Could not parse date/time expression: "${expression}". ` +
128
- `Try: "in 30 minutes", "in 2 hours", "tomorrow at 9am", "next friday at 3pm", or an ISO 8601 datetime.`);
190
+ `Try: "in 30 minutes", "in 2 hours", "tomorrow at 9am", "next friday at 3pm", ` +
191
+ `"às 15h", "hoje às 15:30", "amanhã às 9h", "daqui a 30 minutos", or an ISO 8601 datetime.`);
129
192
  }
130
193
  if (parsed.getTime() <= refDate.getTime()) {
131
194
  throw new Error(`Scheduled time must be in the future. Got: "${expression}" which resolves to ${parsed.toISOString()}.`);
@@ -24,6 +24,71 @@ describe('parseScheduleExpression — once type', () => {
24
24
  expect(result.next_run_at).toBeGreaterThan(REF);
25
25
  expect(result.cron_normalized).toBeNull();
26
26
  });
27
+ it('parses Portuguese "às 15h" in America/Sao_Paulo timezone', () => {
28
+ const result = parseScheduleExpression('às 15h', 'once', {
29
+ timezone: 'America/Sao_Paulo',
30
+ referenceDate: REF,
31
+ });
32
+ expect(result.type).toBe('once');
33
+ expect(result.next_run_at).toBeGreaterThan(REF);
34
+ // Verify the hour is 15 in Sao Paulo timezone
35
+ const dateInTz = new Date(result.next_run_at).toLocaleString('en-US', {
36
+ timeZone: 'America/Sao_Paulo',
37
+ hour: '2-digit',
38
+ hour12: false,
39
+ });
40
+ expect(dateInTz).toContain('15:00');
41
+ });
42
+ it('parses Portuguese "hoje às 15:30"', () => {
43
+ // Use a reference time before 15:30 to ensure it schedules for today
44
+ const morningRef = new Date().setHours(9, 0, 0, 0);
45
+ const result = parseScheduleExpression('hoje às 15:30', 'once', {
46
+ timezone: 'America/Sao_Paulo',
47
+ referenceDate: morningRef,
48
+ });
49
+ expect(result.type).toBe('once');
50
+ const dateInTz = new Date(result.next_run_at).toLocaleString('en-US', {
51
+ timeZone: 'America/Sao_Paulo',
52
+ hour: '2-digit',
53
+ minute: '2-digit',
54
+ hour12: false,
55
+ });
56
+ expect(dateInTz).toContain('15:30');
57
+ });
58
+ it('parses Portuguese "amanhã às 9h"', () => {
59
+ const result = parseScheduleExpression('amanhã às 9h', 'once', {
60
+ timezone: 'America/Sao_Paulo',
61
+ referenceDate: REF,
62
+ });
63
+ expect(result.type).toBe('once');
64
+ const dateInTz = new Date(result.next_run_at).toLocaleString('en-US', {
65
+ timeZone: 'America/Sao_Paulo',
66
+ hour: '2-digit',
67
+ hour12: false,
68
+ });
69
+ expect(dateInTz).toContain('09:00');
70
+ });
71
+ it('parses Portuguese "daqui a 30 minutos"', () => {
72
+ const result = parseScheduleExpression('daqui a 30 minutos', 'once', {
73
+ timezone: 'America/Sao_Paulo',
74
+ referenceDate: REF,
75
+ });
76
+ expect(result.type).toBe('once');
77
+ // Should be approximately 30 minutes from now
78
+ const diffMinutes = (result.next_run_at - REF) / 1000 / 60;
79
+ expect(diffMinutes).toBeGreaterThanOrEqual(29);
80
+ expect(diffMinutes).toBeLessThanOrEqual(31);
81
+ });
82
+ it('parses Portuguese "daqui a 2 horas"', () => {
83
+ const result = parseScheduleExpression('daqui a 2 horas', 'once', {
84
+ timezone: 'America/Sao_Paulo',
85
+ referenceDate: REF,
86
+ });
87
+ expect(result.type).toBe('once');
88
+ const diffHours = (result.next_run_at - REF) / 1000 / 60 / 60;
89
+ expect(diffHours).toBeGreaterThanOrEqual(1.9);
90
+ expect(diffHours).toBeLessThanOrEqual(2.1);
91
+ });
27
92
  });
28
93
  describe('parseScheduleExpression — cron type', () => {
29
94
  it('parses a valid 5-field cron expression', () => {
@@ -5,6 +5,7 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
5
5
  import { ProviderError } from "../errors.js";
6
6
  import { createAgent, createMiddleware } from "langchain";
7
7
  import { DisplayManager } from "../display.js";
8
+ import { getUsableApiKey } from "../trinity-crypto.js";
8
9
  export class ProviderFactory {
9
10
  static buildMonitoringMiddleware() {
10
11
  const display = DisplayManager.getInstance();
@@ -26,24 +27,25 @@ export class ProviderFactory {
26
27
  });
27
28
  }
28
29
  static buildModel(config) {
30
+ const usableApiKey = getUsableApiKey(config.api_key);
29
31
  switch (config.provider) {
30
32
  case 'openai':
31
33
  return new ChatOpenAI({
32
34
  modelName: config.model,
33
35
  temperature: config.temperature,
34
- apiKey: process.env.OPENAI_API_KEY || config.api_key,
36
+ apiKey: process.env.OPENAI_API_KEY || usableApiKey,
35
37
  });
36
38
  case 'anthropic':
37
39
  return new ChatAnthropic({
38
40
  modelName: config.model,
39
41
  temperature: config.temperature,
40
- apiKey: process.env.ANTHROPIC_API_KEY || config.api_key,
42
+ apiKey: process.env.ANTHROPIC_API_KEY || usableApiKey,
41
43
  });
42
44
  case 'openrouter':
43
45
  return new ChatOpenAI({
44
46
  modelName: config.model,
45
47
  temperature: config.temperature,
46
- apiKey: process.env.OPENROUTER_API_KEY || config.api_key,
48
+ apiKey: process.env.OPENROUTER_API_KEY || usableApiKey,
47
49
  configuration: {
48
50
  baseURL: config.base_url || 'https://openrouter.ai/api/v1'
49
51
  }
@@ -52,13 +54,13 @@ export class ProviderFactory {
52
54
  return new ChatOllama({
53
55
  model: config.model,
54
56
  temperature: config.temperature,
55
- baseUrl: config.base_url || config.api_key,
57
+ baseUrl: config.base_url || usableApiKey,
56
58
  });
57
59
  case 'gemini':
58
60
  return new ChatGoogleGenerativeAI({
59
61
  model: config.model,
60
62
  temperature: config.temperature,
61
- apiKey: process.env.GOOGLE_API_KEY || config.api_key
63
+ apiKey: process.env.GOOGLE_API_KEY || usableApiKey
62
64
  });
63
65
  default:
64
66
  throw new Error(`Unsupported provider: ${config.provider}`);