morpheus-cli 0.6.6 → 0.6.8

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.
@@ -7,8 +7,58 @@ import { homedir } from "os";
7
7
  import Database from "better-sqlite3";
8
8
  import { TaskRepository } from "../tasks/repository.js";
9
9
  import { TaskRequestContext } from "../tasks/context.js";
10
+ import { isEnvVarSet } from "../../config/precedence.js";
10
11
  // ─── Shared ───────────────────────────────────────────────────────────────────
11
12
  const shortMemoryDbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
13
+ /**
14
+ * Map of config paths to their corresponding environment variable names.
15
+ * Used to check if a config field is being overridden by an env var.
16
+ */
17
+ const CONFIG_TO_ENV_MAP = {
18
+ 'llm.provider': ['MORPHEUS_LLM_PROVIDER'],
19
+ 'llm.model': ['MORPHEUS_LLM_MODEL'],
20
+ 'llm.temperature': ['MORPHEUS_LLM_TEMPERATURE'],
21
+ 'llm.max_tokens': ['MORPHEUS_LLM_MAX_TOKENS'],
22
+ 'llm.api_key': ['MORPHEUS_LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'],
23
+ 'llm.context_window': ['MORPHEUS_LLM_CONTEXT_WINDOW'],
24
+ 'sati.provider': ['MORPHEUS_SATI_PROVIDER'],
25
+ 'sati.model': ['MORPHEUS_SATI_MODEL'],
26
+ 'sati.temperature': ['MORPHEUS_SATI_TEMPERATURE'],
27
+ 'sati.api_key': ['MORPHEUS_SATI_API_KEY'],
28
+ 'neo.provider': ['MORPHEUS_NEO_PROVIDER'],
29
+ 'neo.model': ['MORPHEUS_NEO_MODEL'],
30
+ 'neo.temperature': ['MORPHEUS_NEO_TEMPERATURE'],
31
+ 'neo.api_key': ['MORPHEUS_NEO_API_KEY'],
32
+ 'apoc.provider': ['MORPHEUS_APOC_PROVIDER'],
33
+ 'apoc.model': ['MORPHEUS_APOC_MODEL'],
34
+ 'apoc.temperature': ['MORPHEUS_APOC_TEMPERATURE'],
35
+ 'apoc.api_key': ['MORPHEUS_APOC_API_KEY'],
36
+ 'apoc.working_dir': ['MORPHEUS_APOC_WORKING_DIR'],
37
+ 'apoc.timeout_ms': ['MORPHEUS_APOC_TIMEOUT_MS'],
38
+ 'trinity.provider': ['MORPHEUS_TRINITY_PROVIDER'],
39
+ 'trinity.model': ['MORPHEUS_TRINITY_MODEL'],
40
+ 'trinity.temperature': ['MORPHEUS_TRINITY_TEMPERATURE'],
41
+ 'trinity.api_key': ['MORPHEUS_TRINITY_API_KEY'],
42
+ 'audio.provider': ['MORPHEUS_AUDIO_PROVIDER'],
43
+ 'audio.model': ['MORPHEUS_AUDIO_MODEL'],
44
+ 'audio.apiKey': ['MORPHEUS_AUDIO_API_KEY'],
45
+ 'audio.maxDurationSeconds': ['MORPHEUS_AUDIO_MAX_DURATION'],
46
+ };
47
+ /**
48
+ * Checks if a config field is overridden by an environment variable.
49
+ */
50
+ function isFieldOverriddenByEnv(fieldPath) {
51
+ const envVars = CONFIG_TO_ENV_MAP[fieldPath];
52
+ if (!envVars)
53
+ return false;
54
+ return envVars.some(envVar => isEnvVarSet(envVar));
55
+ }
56
+ /**
57
+ * Gets all fields that are overridden by environment variables.
58
+ */
59
+ function getOverriddenFields() {
60
+ return Object.keys(CONFIG_TO_ENV_MAP).filter(isFieldOverriddenByEnv);
61
+ }
12
62
  // ─── Config ───────────────────────────────────────────────────────────────────
13
63
  function setNestedValue(obj, dotPath, value) {
14
64
  const keys = dotPath.split(".");
@@ -43,23 +93,37 @@ export const ConfigQueryTool = tool(async ({ key }) => {
43
93
  }),
44
94
  });
45
95
  export const ConfigUpdateTool = tool(async ({ updates }) => {
46
- try {
47
- const configManager = ConfigManager.getInstance();
48
- await configManager.load();
49
- const currentConfig = configManager.get();
50
- const newConfig = { ...currentConfig };
51
- for (const key in updates) {
52
- setNestedValue(newConfig, key, updates[key]);
96
+ const configManager = ConfigManager.getInstance();
97
+ await configManager.load();
98
+ // Check if any fields are overridden by environment variables
99
+ const overriddenFields = [];
100
+ for (const key in updates) {
101
+ if (isFieldOverriddenByEnv(key)) {
102
+ overriddenFields.push(key);
53
103
  }
54
- await configManager.save(newConfig);
55
- return JSON.stringify({ success: true, message: "Configuration updated successfully" });
56
104
  }
57
- catch (error) {
58
- return JSON.stringify({ error: `Failed to update configuration: ${error.message}` });
105
+ if (overriddenFields.length > 0) {
106
+ const envVarNames = overriddenFields
107
+ .flatMap(field => CONFIG_TO_ENV_MAP[field] || [])
108
+ .filter((v, i, arr) => arr.indexOf(v) === i) // Remove duplicates
109
+ .join(', ');
110
+ const errorMsg = `BLOCKED_BY_ENV: Cannot update ${overriddenFields.join(', ')} because these fields are controlled by environment variables (${envVarNames}). To change them, edit your .env file and restart Morpheus.`;
111
+ throw new Error(errorMsg);
112
+ }
113
+ const currentConfig = configManager.get();
114
+ const newConfig = { ...currentConfig };
115
+ for (const key in updates) {
116
+ setNestedValue(newConfig, key, updates[key]);
59
117
  }
118
+ await configManager.save(newConfig);
119
+ return JSON.stringify({
120
+ success: true,
121
+ message: "Configuration updated successfully",
122
+ updatedFields: Object.keys(updates),
123
+ });
60
124
  }, {
61
125
  name: "morpheus_config_update",
62
- description: "Updates configuration values with validation. Accepts an 'updates' object containing key-value pairs to update. Supports dot notation for nested fields (e.g. 'llm.model').",
126
+ description: "Updates configuration values with validation. Accepts an 'updates' object containing key-value pairs to update. Supports dot notation for nested fields (e.g. 'llm.model'). Note: Fields overridden by environment variables cannot be updated.",
63
127
  schema: z.object({
64
128
  updates: z.object({}).passthrough(),
65
129
  }),
@@ -77,30 +141,39 @@ export const DiagnosticTool = tool(async () => {
77
141
  const config = configManager.get();
78
142
  const requiredFields = ["llm", "logging", "ui"];
79
143
  const missingFields = requiredFields.filter((field) => !(field in config));
144
+ // Check MORPHEUS_SECRET
145
+ const hasSecret = !!process.env.MORPHEUS_SECRET;
80
146
  if (missingFields.length === 0) {
81
147
  const sati = config.sati;
82
148
  const apoc = config.apoc;
149
+ const details = {
150
+ oracleProvider: config.llm?.provider,
151
+ oracleModel: config.llm?.model,
152
+ satiProvider: sati?.provider ?? `${config.llm?.provider} (inherited)`,
153
+ satiModel: sati?.model ?? `${config.llm?.model} (inherited)`,
154
+ apocProvider: apoc?.provider ?? `${config.llm?.provider} (inherited)`,
155
+ apocModel: apoc?.model ?? `${config.llm?.model} (inherited)`,
156
+ apocWorkingDir: apoc?.working_dir ?? "not set",
157
+ uiEnabled: config.ui?.enabled,
158
+ uiPort: config.ui?.port,
159
+ morpheusSecret: hasSecret ? "configured ✓" : "NOT SET ⚠️",
160
+ };
83
161
  components.config = {
84
- status: "healthy",
85
- message: "Configuration is valid and complete",
86
- details: {
87
- oracleProvider: config.llm?.provider,
88
- oracleModel: config.llm?.model,
89
- satiProvider: sati?.provider ?? `${config.llm?.provider} (inherited)`,
90
- satiModel: sati?.model ?? `${config.llm?.model} (inherited)`,
91
- apocProvider: apoc?.provider ?? `${config.llm?.provider} (inherited)`,
92
- apocModel: apoc?.model ?? `${config.llm?.model} (inherited)`,
93
- apocWorkingDir: apoc?.working_dir ?? "not set",
94
- uiEnabled: config.ui?.enabled,
95
- uiPort: config.ui?.port,
96
- },
162
+ status: hasSecret ? "healthy" : "warning",
163
+ message: hasSecret
164
+ ? "Configuration is valid and complete"
165
+ : "Configuration is valid but MORPHEUS_SECRET is not set. API keys and database passwords will be stored in plaintext.",
166
+ details,
97
167
  };
98
168
  }
99
169
  else {
100
170
  components.config = {
101
171
  status: "warning",
102
172
  message: `Missing required configuration fields: ${missingFields.join(", ")}`,
103
- details: { missingFields },
173
+ details: {
174
+ missingFields,
175
+ morpheusSecret: hasSecret ? "configured ✓" : "NOT SET ⚠️",
176
+ },
104
177
  };
105
178
  }
106
179
  }
@@ -16,6 +16,27 @@ const casualChrono = new chrono.Chrono({
16
16
  ...chrono.es.casual.refiners,
17
17
  ],
18
18
  });
19
+ /**
20
+ * Formats a Date as ISO string with timezone offset (not UTC).
21
+ * Example: "2026-02-25T17:25:00-03:00" instead of "2026-02-25T17:25:00.000Z"
22
+ */
23
+ function formatDateWithTimezone(date, timezone) {
24
+ // Get the offset for the given timezone
25
+ const offsetMinutes = -new Date(date.toLocaleString('en-US', { timeZone: timezone, timeZoneName: 'longOffset' })).getTimezoneOffset();
26
+ // Format date without timezone
27
+ const year = date.getFullYear();
28
+ const month = String(date.getMonth() + 1).padStart(2, '0');
29
+ const day = String(date.getDate()).padStart(2, '0');
30
+ const hours = String(date.getHours()).padStart(2, '0');
31
+ const minutes = String(date.getMinutes()).padStart(2, '0');
32
+ const seconds = String(date.getSeconds()).padStart(2, '0');
33
+ // Calculate offset string
34
+ const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);
35
+ const offsetMins = Math.abs(offsetMinutes) % 60;
36
+ const offsetSign = offsetMinutes >= 0 ? '+' : '-';
37
+ const offsetStr = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMins).padStart(2, '0')}`;
38
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetStr}`;
39
+ }
19
40
  export const timeVerifierTool = tool(async ({ text, timezone }) => {
20
41
  // If a timezone is provided, use it for parsing context.
21
42
  // Otherwise, use the configured system timezone from Chronos.
@@ -36,12 +57,17 @@ export const timeVerifierTool = tool(async ({ text, timezone }) => {
36
57
  const parsed = results.map((result) => {
37
58
  const startDate = result.start.date();
38
59
  const endDate = result.end?.date();
60
+ // Format the date in the user's timezone for clarity
61
+ const formatted = startDate.toLocaleString('pt-BR', { timeZone: effectiveTimezone, timeZoneName: 'short' });
62
+ // Convert to ISO string WITH timezone offset (not UTC)
63
+ // This ensures Chronos schedules at the correct local time
64
+ const isoStart = formatDateWithTimezone(startDate, effectiveTimezone);
65
+ const isoEnd = endDate ? formatDateWithTimezone(endDate, effectiveTimezone) : null;
39
66
  return {
40
67
  expression: result.text,
41
- isoStart: startDate.toISOString(),
42
- isoEnd: endDate ? endDate.toISOString() : null,
43
- // Format the date in the user's timezone for clarity
44
- formatted: startDate.toLocaleString('pt-BR', { timeZone: effectiveTimezone, timeZoneName: 'short' }),
68
+ isoStart,
69
+ isoEnd,
70
+ formatted,
45
71
  isRange: !!endDate,
46
72
  };
47
73
  });