morpheus-cli 0.9.23 → 0.9.30

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.
Files changed (57) hide show
  1. package/dist/channels/telegram.js +165 -120
  2. package/dist/cli/commands/restart.js +27 -0
  3. package/dist/cli/commands/start.js +9 -9
  4. package/dist/config/manager.js +34 -2
  5. package/dist/config/paths.js +2 -0
  6. package/dist/config/schemas.js +6 -0
  7. package/dist/http/webhooks-router.js +12 -6
  8. package/dist/runtime/__tests__/gws-sync.test.js +69 -0
  9. package/dist/runtime/gws-sync.js +102 -0
  10. package/dist/runtime/hash-utils.js +15 -0
  11. package/dist/runtime/hot-reload.js +5 -1
  12. package/dist/runtime/oracle.js +1 -0
  13. package/dist/runtime/scaffold.js +4 -0
  14. package/dist/runtime/skills/loader.js +7 -52
  15. package/dist/runtime/skills/registry.js +5 -1
  16. package/dist/runtime/skills/schema.js +1 -1
  17. package/dist/runtime/skills/tool.js +8 -1
  18. package/dist/runtime/subagents/apoc.js +154 -1
  19. package/dist/runtime/subagents/devkit-instrument.js +13 -0
  20. package/dist/runtime/webhooks/dispatcher.js +12 -4
  21. package/dist/runtime/webhooks/repository.js +17 -6
  22. package/dist/types/config.js +3 -0
  23. package/dist/ui/assets/{AuditDashboard-ClqEr7jg.js → AuditDashboard-tH9QZTl4.js} +1 -1
  24. package/dist/ui/assets/{Chat-BwxZJphx.js → Chat-Cd0uYF8g.js} +1 -1
  25. package/dist/ui/assets/{Chronos-BafOMteb.js → Chronos-CWwHYdBl.js} +1 -1
  26. package/dist/ui/assets/{ConfirmationModal-DU0AwhXD.js → ConfirmationModal-CxvFe-We.js} +1 -1
  27. package/dist/ui/assets/{Dashboard-DvJb72Xe.js → Dashboard-CNNMxl53.js} +1 -1
  28. package/dist/ui/assets/{DeleteConfirmationModal-FSWLK6-I.js → DeleteConfirmationModal-AqGQSh1X.js} +1 -1
  29. package/dist/ui/assets/{Documents-D73CeGkW.js → Documents-BfRYOK88.js} +1 -1
  30. package/dist/ui/assets/{Logs-BrFWnLIL.js → Logs-DhFo4cio.js} +1 -1
  31. package/dist/ui/assets/{MCPManager-_L2Yo-uY.js → MCPManager-BMhxbhni.js} +1 -1
  32. package/dist/ui/assets/{ModelPricing-CyXMdxJD.js → ModelPricing-Dvl0R_HR.js} +1 -1
  33. package/dist/ui/assets/{Notifications-BpHokTLS.js → Notifications-CawvBid4.js} +1 -1
  34. package/dist/ui/assets/{SatiMemories-CfSTgr9V.js → SatiMemories-yyVrJGdc.js} +1 -1
  35. package/dist/ui/assets/{SessionAudit-pOWRgJtc.js → SessionAudit-joq0ntdJ.js} +1 -1
  36. package/dist/ui/assets/{Settings-CPDXAk18.js → Settings-B6SMPn41.js} +7 -7
  37. package/dist/ui/assets/{Skills-GIkCxMS3.js → Skills-B5yhTHyn.js} +1 -1
  38. package/dist/ui/assets/{Smiths-ZcHXcrMt.js → Smiths-Dug63YED.js} +1 -1
  39. package/dist/ui/assets/{Tasks-DJ6R3d4f.js → Tasks-D8HPLkg0.js} +1 -1
  40. package/dist/ui/assets/{TrinityDatabases-CnRAkDuu.js → TrinityDatabases-D0qEKmwJ.js} +1 -1
  41. package/dist/ui/assets/{UsageStats-Bl7bs4ay.js → UsageStats-CHWALN70.js} +1 -1
  42. package/dist/ui/assets/WebhookManager-T2ef90p8.js +4 -0
  43. package/dist/ui/assets/{agents-DO69pNM1.js → agents-BVnfnJ1X.js} +1 -1
  44. package/dist/ui/assets/{audit-CP5fC4m8.js → audit-BErc_ye8.js} +1 -1
  45. package/dist/ui/assets/{chronos-DPhK718h.js → chronos-CAv__H3B.js} +1 -1
  46. package/dist/ui/assets/{config-OLGQFNJL.js → config-CPFW7PTY.js} +1 -1
  47. package/dist/ui/assets/{index-B9ePr-vB.js → index-BvsF1a9j.js} +2 -2
  48. package/dist/ui/assets/index-gx__iEcl.css +1 -0
  49. package/dist/ui/assets/{mcp-BeBznKtK.js → mcp-BaHwY4DW.js} +1 -1
  50. package/dist/ui/assets/{skills-wEUxSGB3.js → skills-lbjIRO8d.js} +1 -1
  51. package/dist/ui/assets/{stats-KMbKDMJ-.js → stats-C8KAfpHO.js} +1 -1
  52. package/dist/ui/assets/{useCurrency-Bgg-7MTE.js → useCurrency-Ch0lsvGj.js} +1 -1
  53. package/dist/ui/index.html +2 -2
  54. package/dist/ui/sw.js +1 -1
  55. package/package.json +1 -1
  56. package/dist/ui/assets/WebhookManager-CEjjk4tx.js +0 -4
  57. package/dist/ui/assets/index-D_0tPLCk.css +0 -1
@@ -266,13 +266,42 @@ export class TelegramAdapter {
266
266
  // Send "typing" status
267
267
  await ctx.sendChatAction('typing');
268
268
  const sessionId = await this.getSessionForUser(userId);
269
- // Process with Agent
270
- const response = await this.oracle.chat(text, undefined, false, {
269
+ // Process with Agent - with a 30s timeout to avoid blocking the bot's update loop.
270
+ const CHAT_TIMEOUT_MS = 30000;
271
+ let timeoutTriggered = false;
272
+ const chatPromise = this.oracle.chat(text, undefined, false, {
271
273
  origin_channel: 'telegram',
272
274
  session_id: sessionId,
273
275
  origin_message_id: String(ctx.message.message_id),
274
276
  origin_user_id: userId,
275
277
  });
278
+ const response = await Promise.race([
279
+ chatPromise,
280
+ new Promise((resolve) => setTimeout(() => {
281
+ timeoutTriggered = true;
282
+ resolve(null);
283
+ }, CHAT_TIMEOUT_MS))
284
+ ]);
285
+ if (timeoutTriggered) {
286
+ await ctx.reply("⏳ I'm still working on that, it's taking a bit longer than usual. I'll send the response as soon as it's ready!");
287
+ // Wait for actual response in background
288
+ const finalResponse = await chatPromise;
289
+ if (finalResponse) {
290
+ const rich = await toTelegramRichText(finalResponse);
291
+ for (const chunk of rich.chunks) {
292
+ try {
293
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
294
+ }
295
+ catch {
296
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
297
+ if (plain)
298
+ await ctx.reply(plain);
299
+ }
300
+ }
301
+ this.display.log(`Responded to @${user} (delayed): ${finalResponse.slice(0, 50)}...`, { source: 'Telegram' });
302
+ }
303
+ return;
304
+ }
276
305
  if (response) {
277
306
  const rich = await toTelegramRichText(response);
278
307
  for (const chunk of rich.chunks) {
@@ -285,7 +314,7 @@ export class TelegramAdapter {
285
314
  await ctx.reply(plain);
286
315
  }
287
316
  }
288
- this.display.log(`Responded to @${user}: ${response}`, { source: 'Telegram' });
317
+ this.display.log(`Responded to @${user}: ${response.slice(0, 50)}...`, { source: 'Telegram' });
289
318
  }
290
319
  }
291
320
  catch (error) {
@@ -389,126 +418,33 @@ export class TelegramAdapter {
389
418
  await ctx.reply(`🎤 Transcription: "${text}"`);
390
419
  await ctx.sendChatAction('typing');
391
420
  const sessionId = await this.getSessionForUser(userId);
392
- // Process with Agent
393
- const response = await this.oracle.chat(text, usage, true, {
421
+ // Process with Agent - with a 30s timeout to avoid blocking the bot's update loop.
422
+ const CHAT_TIMEOUT_MS = 30000;
423
+ let timeoutTriggered = false;
424
+ const chatPromise = this.oracle.chat(text, usage, true, {
394
425
  origin_channel: 'telegram',
395
426
  session_id: sessionId,
396
427
  origin_message_id: String(ctx.message.message_id),
397
428
  origin_user_id: userId,
398
429
  });
399
- // if (listeningMsg) {
400
- // try {
401
- // await ctx.telegram.deleteMessage(ctx.chat.id, listeningMsg.message_id);
402
- // } catch (e) {
403
- // // Ignore delete error
404
- // }
405
- // }
406
- if (response) {
407
- const ttsConfig = config.audio.tts;
408
- if (ttsConfig?.enabled && this.ttsTelephonist?.synthesize) {
409
- // TTS path: synthesize and send as voice message
410
- let ttsFilePath = null;
411
- const ttsStart = Date.now();
412
- try {
413
- this.display.startActivity('telephonist', 'Synthesizing TTS...');
414
- const ttsApiKey = getUsableApiKey(ttsConfig.apiKey) ||
415
- getUsableApiKey(config.audio.apiKey) ||
416
- (config.llm.provider === (ttsConfig.provider === 'google' ? 'gemini' : ttsConfig.provider)
417
- ? getUsableApiKey(config.llm.api_key) : undefined);
418
- const ttsResult = await this.ttsTelephonist.synthesize(response, ttsApiKey || '', ttsConfig.voice, ttsConfig.style_prompt);
419
- ttsFilePath = ttsResult.filePath;
420
- const ttsDurationMs = Date.now() - ttsStart;
421
- this.display.endActivity('telephonist', true);
422
- // OGG/Opus → replyWithVoice; everything else (mp3, wav) → replyWithAudio
423
- const isOgg = ttsResult.mimeType.includes('ogg') || ttsResult.mimeType.includes('opus');
424
- if (isOgg) {
425
- await ctx.replyWithVoice({ source: ttsFilePath });
426
- }
427
- else {
428
- await ctx.replyWithAudio({ source: ttsFilePath });
429
- }
430
- this.display.log(`Responded to @${user} (TTS audio)`, { source: 'Telegram' });
431
- // Audit TTS success
432
- try {
433
- const auditSessionId = await this.getSessionForUser(userId);
434
- AuditRepository.getInstance().insert({
435
- session_id: auditSessionId,
436
- event_type: 'telephonist',
437
- agent: 'telephonist',
438
- provider: ttsConfig.provider,
439
- model: ttsConfig.model,
440
- input_tokens: ttsResult.usage.input_tokens || null,
441
- output_tokens: ttsResult.usage.output_tokens || null,
442
- duration_ms: ttsDurationMs,
443
- status: 'success',
444
- metadata: {
445
- operation: 'tts',
446
- characters: response.length,
447
- voice: ttsConfig.voice,
448
- user,
449
- },
450
- });
451
- }
452
- catch {
453
- // Audit failure never breaks the main flow
454
- }
455
- }
456
- catch (ttsError) {
457
- this.display.endActivity('telephonist', false);
458
- const ttsDetail = ttsError?.message || String(ttsError);
459
- this.display.log(`TTS synthesis failed for @${user}: ${ttsDetail} — falling back to text`, { source: 'Telephonist', level: 'warning' });
460
- // Audit TTS failure
461
- try {
462
- const auditSessionId = await this.getSessionForUser(userId);
463
- AuditRepository.getInstance().insert({
464
- session_id: auditSessionId,
465
- event_type: 'telephonist',
466
- agent: 'telephonist',
467
- provider: ttsConfig.provider,
468
- model: ttsConfig.model,
469
- duration_ms: Date.now() - ttsStart,
470
- status: 'error',
471
- metadata: { operation: 'tts', error: ttsDetail, user },
472
- });
473
- }
474
- catch {
475
- // Audit failure never breaks the main flow
476
- }
477
- // Fallback to text
478
- const rich = await toTelegramRichText(response);
479
- for (const chunk of rich.chunks) {
480
- try {
481
- await ctx.reply(chunk, { parse_mode: rich.parse_mode });
482
- }
483
- catch {
484
- const plain = stripHtmlTags(chunk).slice(0, 4096);
485
- if (plain)
486
- await ctx.reply(plain);
487
- }
488
- }
489
- }
490
- finally {
491
- // Cleanup TTS temp file
492
- if (ttsFilePath) {
493
- await fs.promises.unlink(ttsFilePath).catch(() => { });
494
- }
495
- }
496
- }
497
- else {
498
- // Text path (TTS disabled)
499
- const rich = await toTelegramRichText(response);
500
- for (const chunk of rich.chunks) {
501
- try {
502
- await ctx.reply(chunk, { parse_mode: rich.parse_mode });
503
- }
504
- catch {
505
- const plain = stripHtmlTags(chunk).slice(0, 4096);
506
- if (plain)
507
- await ctx.reply(plain);
508
- }
509
- }
510
- this.display.log(`Responded to @${user} (via audio)`, { source: 'Telegram' });
430
+ const response = await Promise.race([
431
+ chatPromise,
432
+ new Promise((resolve) => setTimeout(() => {
433
+ timeoutTriggered = true;
434
+ resolve(null);
435
+ }, CHAT_TIMEOUT_MS))
436
+ ]);
437
+ if (timeoutTriggered) {
438
+ await ctx.reply("⏳ I'm still working on that, it's taking a bit longer than usual. I'll send the response as soon as it's ready!");
439
+ // Wait for actual response in background
440
+ const finalResponse = await chatPromise;
441
+ if (finalResponse) {
442
+ await this.handleAgentResponse(ctx, finalResponse, config, user, userId);
511
443
  }
444
+ return;
445
+ }
446
+ if (response) {
447
+ await this.handleAgentResponse(ctx, response, config, user, userId);
512
448
  }
513
449
  }
514
450
  catch (error) {
@@ -876,11 +812,17 @@ export class TelegramAdapter {
876
812
  ctx.answerCbQuery(`Error: ${error.message}`).catch(() => { });
877
813
  }
878
814
  });
879
- this.bot.launch().catch((err) => {
880
- if (this.isConnected) {
881
- this.display.log(`Telegram bot error: ${err}`, { source: 'Telegram', level: 'error' });
815
+ // Global error handler to prevent crashes on long-running tasks or network errors
816
+ this.bot.catch((err, ctx) => {
817
+ const updateId = ctx?.update?.update_id;
818
+ const errorMsg = err?.message || String(err);
819
+ this.display.log(`Unhandled Telegram error (Update: ${updateId}): ${errorMsg}`, { source: 'Telegram', level: 'error' });
820
+ // If it's a timeout error, notify the user if possible
821
+ if (errorMsg.includes('timeout') || errorMsg.includes('90000')) {
822
+ ctx.reply("⏳ Sorry, that request is taking longer than expected. I'm still working on it, but the connection timed out. I'll notify you once it's done.").catch(() => { });
882
823
  }
883
824
  });
825
+ this.launchBot();
884
826
  this.isConnected = true;
885
827
  // Check if there's a restart notification to send
886
828
  this.checkAndSendRestartNotification().catch((err) => {
@@ -1955,4 +1897,107 @@ How can I assist you today?`;
1955
1897
  await ctx.reply('An error occurred while retrieving the list of MCP servers\\. Please check the logs for more details\\.', { parse_mode: 'MarkdownV2' });
1956
1898
  }
1957
1899
  }
1900
+ launchBot() {
1901
+ if (!this.bot)
1902
+ return;
1903
+ this.bot.launch().catch((err) => {
1904
+ this.display.log(`Telegram bot error: ${err.message || String(err)}`, { source: 'Telegram', level: 'error' });
1905
+ if (this.isConnected) {
1906
+ this.display.log('Attempting to restart Telegram bot in 5 seconds...', { source: 'Telegram', level: 'info' });
1907
+ setTimeout(() => {
1908
+ if (this.isConnected)
1909
+ this.launchBot();
1910
+ }, 5000);
1911
+ }
1912
+ });
1913
+ }
1914
+ async handleAgentResponse(ctx, response, config, user, userId) {
1915
+ const ttsConfig = config.audio.tts;
1916
+ if (ttsConfig?.enabled && this.ttsTelephonist?.synthesize) {
1917
+ // TTS path: synthesize and send as voice message
1918
+ let ttsFilePath = null;
1919
+ const ttsStart = Date.now();
1920
+ try {
1921
+ this.display.startActivity('telephonist', 'Synthesizing TTS...');
1922
+ const ttsApiKey = getUsableApiKey(ttsConfig.apiKey) ||
1923
+ getUsableApiKey(config.audio.apiKey) ||
1924
+ (config.llm.provider === (ttsConfig.provider === 'google' ? 'gemini' : ttsConfig.provider)
1925
+ ? getUsableApiKey(config.llm.api_key) : undefined);
1926
+ const ttsResult = await this.ttsTelephonist.synthesize(response, ttsApiKey || '', ttsConfig.voice, ttsConfig.style_prompt);
1927
+ ttsFilePath = ttsResult.filePath;
1928
+ const ttsDurationMs = Date.now() - ttsStart;
1929
+ this.display.endActivity('telephonist', true);
1930
+ // OGG/Opus → replyWithVoice; everything else (mp3, wav) → replyWithAudio
1931
+ const isOgg = ttsResult.mimeType.includes('ogg') || ttsResult.mimeType.includes('opus');
1932
+ if (isOgg) {
1933
+ await ctx.replyWithVoice({ source: ttsFilePath });
1934
+ }
1935
+ else {
1936
+ await ctx.replyWithAudio({ source: ttsFilePath });
1937
+ }
1938
+ this.display.log(`Responded to @${user} (TTS audio)`, { source: 'Telegram' });
1939
+ // Audit TTS success
1940
+ try {
1941
+ const auditSessionId = await this.getSessionForUser(userId);
1942
+ AuditRepository.getInstance().insert({
1943
+ session_id: auditSessionId,
1944
+ event_type: 'telephonist',
1945
+ agent: 'telephonist',
1946
+ provider: ttsConfig.provider,
1947
+ model: ttsConfig.model,
1948
+ input_tokens: ttsResult.usage.input_tokens || null,
1949
+ output_tokens: ttsResult.usage.output_tokens || null,
1950
+ duration_ms: ttsDurationMs,
1951
+ status: 'success',
1952
+ metadata: {
1953
+ operation: 'tts',
1954
+ characters: response.length,
1955
+ voice: ttsConfig.voice,
1956
+ user,
1957
+ },
1958
+ });
1959
+ }
1960
+ catch {
1961
+ // Audit failure never breaks the main flow
1962
+ }
1963
+ }
1964
+ catch (ttsError) {
1965
+ this.display.endActivity('telephonist', false);
1966
+ const ttsDetail = ttsError?.message || String(ttsError);
1967
+ this.display.log(`TTS synthesis failed for @${user}: ${ttsDetail} — falling back to text`, { source: 'Telephonist', level: 'warning' });
1968
+ // Fallback to text
1969
+ const rich = await toTelegramRichText(response);
1970
+ for (const chunk of rich.chunks) {
1971
+ try {
1972
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
1973
+ }
1974
+ catch {
1975
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
1976
+ if (plain)
1977
+ await ctx.reply(plain);
1978
+ }
1979
+ }
1980
+ }
1981
+ finally {
1982
+ if (ttsFilePath) {
1983
+ await fs.promises.unlink(ttsFilePath).catch(() => { });
1984
+ }
1985
+ }
1986
+ }
1987
+ else {
1988
+ // Text path
1989
+ const rich = await toTelegramRichText(response);
1990
+ for (const chunk of rich.chunks) {
1991
+ try {
1992
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
1993
+ }
1994
+ catch {
1995
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
1996
+ if (plain)
1997
+ await ctx.reply(plain);
1998
+ }
1999
+ }
2000
+ this.display.log(`Responded to @${user}`, { source: 'Telegram' });
2001
+ }
2002
+ }
1958
2003
  }
@@ -9,6 +9,7 @@ import { renderBanner } from '../utils/render.js';
9
9
  import { TelegramAdapter } from '../../channels/telegram.js';
10
10
  import { ChannelRegistry } from '../../channels/registry.js';
11
11
  import { PATHS } from '../../config/paths.js';
12
+ import { SkillRegistry } from '../../runtime/skills/index.js';
12
13
  import { Oracle } from '../../runtime/oracle.js';
13
14
  import { ProviderError } from '../../runtime/errors.js';
14
15
  import { HttpServer } from '../../http/server.js';
@@ -17,6 +18,12 @@ import { TaskWorker } from '../../runtime/tasks/worker.js';
17
18
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
18
19
  import { Link } from '../../runtime/subagents/link/link.js';
19
20
  import { LinkWorker } from '../../runtime/subagents/link/worker.js';
21
+ import { ServiceContainer, SERVICE_KEYS } from '../../runtime/container.js';
22
+ import { ChannelNotifierAdapter } from '../../runtime/adapters/ChannelNotifierAdapter.js';
23
+ import { SQLiteTaskEnqueuerAdapter } from '../../runtime/adapters/SQLiteTaskEnqueuerAdapter.js';
24
+ import { SQLiteChatHistoryAdapter } from '../../runtime/adapters/SQLiteChatHistoryAdapter.js';
25
+ import { LangChainProviderAdapter } from '../../runtime/adapters/LangChainProviderAdapter.js';
26
+ import { AuditRepositoryAdapter } from '../../runtime/adapters/AuditRepositoryAdapter.js';
20
27
  export const restartCommand = new Command('restart')
21
28
  .description('Restart the Morpheus agent')
22
29
  .option('--ui', 'Enable web UI', true)
@@ -67,11 +74,31 @@ export const restartCommand = new Command('restart')
67
74
  const config = await configManager.load();
68
75
  // Initialize persistent logging
69
76
  await display.initialize(config.logging);
77
+ // ── Composition Root ─────────────────────────────────────────────────────
78
+ // Register port adapters in the ServiceContainer so consumers can
79
+ // depend on interfaces instead of concrete implementations.
80
+ ServiceContainer.register(SERVICE_KEYS.notifier, new ChannelNotifierAdapter());
81
+ ServiceContainer.register(SERVICE_KEYS.taskEnqueuer, new SQLiteTaskEnqueuerAdapter());
82
+ ServiceContainer.register(SERVICE_KEYS.chatHistory, new SQLiteChatHistoryAdapter());
83
+ ServiceContainer.register(SERVICE_KEYS.providerFactory, new LangChainProviderAdapter());
84
+ ServiceContainer.register(SERVICE_KEYS.auditEmitter, new AuditRepositoryAdapter());
85
+ // ─────────────────────────────────────────────────────────────────────────
70
86
  display.log(chalk.green(`Morpheus Agent (${config.agent.name}) starting...`), { source: 'Zaion' });
71
87
  display.log(chalk.gray(`PID: ${process.pid}`), { source: 'Zaion' });
72
88
  if (options.ui) {
73
89
  display.log(chalk.blue(`Web UI enabled to port ${options.port}`), { source: 'Zaion' });
74
90
  }
91
+ // Initialize SkillRegistry before Oracle (so skills are available in system prompt)
92
+ try {
93
+ const skillRegistry = SkillRegistry.getInstance();
94
+ await skillRegistry.load();
95
+ const loadedSkills = skillRegistry.getAll();
96
+ const enabledCount = skillRegistry.getEnabled().length;
97
+ display.log(chalk.green(`✓ Skills loaded: ${loadedSkills.length} total, ${enabledCount} enabled`), { source: 'Skills' });
98
+ }
99
+ catch (err) {
100
+ display.log(chalk.yellow(`Skills initialization warning: ${err.message}`), { source: 'Skills' });
101
+ }
75
102
  // Initialize Oracle
76
103
  const oracle = new Oracle(config);
77
104
  try {
@@ -133,6 +133,15 @@ export const startCommand = new Command('start')
133
133
  const config = await configManager.load();
134
134
  // Initialize persistent logging
135
135
  await display.initialize(config.logging);
136
+ // ── Composition Root ─────────────────────────────────────────────────────
137
+ // Register port adapters in the ServiceContainer so consumers can
138
+ // depend on interfaces instead of concrete implementations.
139
+ ServiceContainer.register(SERVICE_KEYS.notifier, new ChannelNotifierAdapter());
140
+ ServiceContainer.register(SERVICE_KEYS.taskEnqueuer, new SQLiteTaskEnqueuerAdapter());
141
+ ServiceContainer.register(SERVICE_KEYS.chatHistory, new SQLiteChatHistoryAdapter());
142
+ ServiceContainer.register(SERVICE_KEYS.providerFactory, new LangChainProviderAdapter());
143
+ ServiceContainer.register(SERVICE_KEYS.auditEmitter, new AuditRepositoryAdapter());
144
+ // ─────────────────────────────────────────────────────────────────────────
136
145
  display.log(chalk.green(`Morpheus Agent (${config.agent.name}) starting...`));
137
146
  display.log(chalk.gray(`PID: ${process.pid}`));
138
147
  if (options.ui) {
@@ -220,15 +229,6 @@ export const startCommand = new Command('start')
220
229
  await clearPid();
221
230
  process.exit(1);
222
231
  }
223
- // ── Composition Root ─────────────────────────────────────────────────────
224
- // Register port adapters in the ServiceContainer so consumers can
225
- // depend on interfaces instead of concrete implementations.
226
- ServiceContainer.register(SERVICE_KEYS.notifier, new ChannelNotifierAdapter());
227
- ServiceContainer.register(SERVICE_KEYS.taskEnqueuer, new SQLiteTaskEnqueuerAdapter());
228
- ServiceContainer.register(SERVICE_KEYS.chatHistory, new SQLiteChatHistoryAdapter());
229
- ServiceContainer.register(SERVICE_KEYS.providerFactory, new LangChainProviderAdapter());
230
- ServiceContainer.register(SERVICE_KEYS.auditEmitter, new AuditRepositoryAdapter());
231
- // ─────────────────────────────────────────────────────────────────────────
232
232
  const adapters = [];
233
233
  let httpServer;
234
234
  const taskWorker = new TaskWorker();
@@ -343,6 +343,11 @@ export class ConfigManager {
343
343
  max_active_jobs: resolveNumeric('MORPHEUS_CHRONOS_MAX_ACTIVE_JOBS', config.chronos.max_active_jobs, 100),
344
344
  };
345
345
  }
346
+ // Apply precedence to GWS config
347
+ const gwsConfig = {
348
+ service_account_json: resolveString('MORPHEUS_GWS_SERVICE_ACCOUNT_JSON', config.gws?.service_account_json, ''),
349
+ enabled: resolveBoolean('MORPHEUS_GWS_ENABLED', config.gws?.enabled, true),
350
+ };
346
351
  // Apply precedence to DevKit config
347
352
  // Migration: if devkit is absent but apoc.working_dir exists, migrate it
348
353
  const rawDevKit = config.devkit ?? {};
@@ -409,6 +414,22 @@ export class ConfigManager {
409
414
  async save(newConfig) {
410
415
  // Deep merge or overwrite? simpler to overwrite for now or merge top level
411
416
  let updated = { ...this.config, ...newConfig };
417
+ // If GWS credentials string is provided, save it to file
418
+ if (newConfig.gws?.service_account_json_content) {
419
+ const content = newConfig.gws.service_account_json_content;
420
+ try {
421
+ // Validate JSON
422
+ JSON.parse(content);
423
+ await fs.ensureDir(PATHS.gws);
424
+ await fs.writeFile(PATHS.gwsCredentials, content, 'utf8');
425
+ // Update the path in config and remove the raw content
426
+ newConfig.gws.service_account_json = PATHS.gwsCredentials;
427
+ delete newConfig.gws.service_account_json_content;
428
+ }
429
+ catch (err) {
430
+ throw new Error(`Invalid Google Service Account JSON: ${err instanceof Error ? err.message : String(err)}`);
431
+ }
432
+ }
412
433
  // Encrypt API keys before saving if MORPHEUS_SECRET is set
413
434
  updated = this.encryptAgentApiKeys(updated);
414
435
  // Validate before saving
@@ -524,7 +545,7 @@ export class ConfigManager {
524
545
  sandbox_dir: process.cwd(),
525
546
  readonly_mode: false,
526
547
  allowed_shell_commands: [],
527
- allowed_paths: [PATHS.docs, PATHS.skills],
548
+ allowed_paths: [PATHS.docs, PATHS.skills, PATHS.gws],
528
549
  enable_filesystem: true,
529
550
  enable_shell: true,
530
551
  enable_git: true,
@@ -535,12 +556,23 @@ export class ConfigManager {
535
556
  const merged = { ...defaults, ...this.config.devkit };
536
557
  // Ensure allowed_paths has default if empty or undefined
537
558
  if (!merged.allowed_paths?.length) {
538
- merged.allowed_paths = [PATHS.docs, PATHS.skills];
559
+ merged.allowed_paths = [PATHS.docs, PATHS.skills, PATHS.gws];
560
+ }
561
+ else if (!merged.allowed_paths.includes(PATHS.gws)) {
562
+ // Force inclusion of GWS path if it's not there
563
+ merged.allowed_paths.push(PATHS.gws);
539
564
  }
540
565
  return merged;
541
566
  }
542
567
  return defaults;
543
568
  }
569
+ getGwsConfig() {
570
+ const defaults = { enabled: true };
571
+ if (this.config.gws) {
572
+ return { ...defaults, ...this.config.gws };
573
+ }
574
+ return defaults;
575
+ }
544
576
  /**
545
577
  * Returns encryption status for all agent API keys.
546
578
  */
@@ -16,6 +16,8 @@ export const PATHS = {
16
16
  mcps: path.join(MORPHEUS_ROOT, 'mcps.json'),
17
17
  skills: path.join(MORPHEUS_ROOT, 'skills'),
18
18
  docs: path.join(MORPHEUS_ROOT, 'docs'),
19
+ gws: path.join(MORPHEUS_ROOT, 'gws'),
20
+ gwsCredentials: path.join(MORPHEUS_ROOT, 'gws', 'credentials.json'),
19
21
  shortMemoryDb: path.join(MORPHEUS_ROOT, 'memory', 'short-memory.db'),
20
22
  trinityDb: path.join(MORPHEUS_ROOT, 'memory', 'trinity.db'),
21
23
  satiDb: path.join(MORPHEUS_ROOT, 'memory', 'sati-memory.db'),
@@ -98,6 +98,11 @@ export const CurrencyConfigSchema = z.object({
98
98
  symbol: z.string().min(1).default('$'),
99
99
  rate: z.number().positive().default(1.0),
100
100
  });
101
+ export const GwsConfigSchema = z.object({
102
+ service_account_json: z.string().optional(),
103
+ service_account_json_content: z.string().optional(),
104
+ enabled: z.boolean().default(true),
105
+ });
101
106
  // Zod Schema matching MorpheusConfig interface
102
107
  export const ConfigSchema = z.object({
103
108
  agent: z.object({
@@ -123,6 +128,7 @@ export const ConfigSchema = z.object({
123
128
  chronos: ChronosConfigSchema.optional(),
124
129
  devkit: DevKitConfigSchema.optional(),
125
130
  smiths: SmithsConfigSchema.optional(),
131
+ gws: GwsConfigSchema.optional(),
126
132
  setup: SetupConfigSchema.optional(),
127
133
  currency: CurrencyConfigSchema.optional(),
128
134
  verbose_mode: z.boolean().default(true),
@@ -12,6 +12,7 @@ const CreateWebhookSchema = z.object({
12
12
  .regex(/^[a-z0-9-_]+$/, 'Name must be a slug: lowercase letters, numbers, hyphens, underscores only'),
13
13
  prompt: z.string().min(1).max(10_000),
14
14
  notification_channels: z.array(z.enum(['ui', 'telegram', 'discord'])).min(1).default(['ui']),
15
+ requires_api_key: z.boolean().default(true),
15
16
  });
16
17
  const UpdateWebhookSchema = z.object({
17
18
  name: z
@@ -23,6 +24,7 @@ const UpdateWebhookSchema = z.object({
23
24
  prompt: z.string().min(1).max(10_000).optional(),
24
25
  enabled: z.boolean().optional(),
25
26
  notification_channels: z.array(z.enum(['ui', 'telegram', 'discord'])).min(1).optional(),
27
+ requires_api_key: z.boolean().optional(),
26
28
  });
27
29
  const MarkReadSchema = z.object({
28
30
  ids: z.array(z.string().uuid()).min(1),
@@ -39,14 +41,18 @@ export function createWebhooksRouter() {
39
41
  router.post('/trigger/:webhook_name', async (req, res) => {
40
42
  const { webhook_name } = req.params;
41
43
  const apiKey = req.headers['x-api-key'];
42
- if (!apiKey || typeof apiKey !== 'string') {
43
- return res.status(401).json({ error: 'Missing x-api-key header' });
44
- }
45
- const webhook = repo.getAndValidateWebhook(webhook_name, apiKey);
46
- if (!webhook) {
47
- // Intentionally ambiguous — don't reveal whether the name exists or is disabled
44
+ // 1. First lookup by name to check if it requires an API key
45
+ const webhook = repo.getWebhookByName(webhook_name);
46
+ if (!webhook || !webhook.enabled) {
47
+ // Intentionally ambiguous
48
48
  return res.status(401).json({ error: 'Invalid webhook name or api key' });
49
49
  }
50
+ // 2. Validate API key if required
51
+ if (webhook.requires_api_key) {
52
+ if (!apiKey || typeof apiKey !== 'string' || apiKey !== webhook.api_key) {
53
+ return res.status(401).json({ error: 'Invalid webhook name or api key' });
54
+ }
55
+ }
50
56
  const payload = req.body ?? {};
51
57
  // Create pending notification immediately
52
58
  const notification = repo.createNotification({
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { syncGwsSkills } from '../gws-sync.js';
5
+ import { ConfigManager } from '../../config/manager.js';
6
+ import { calculateMd5 } from '../hash-utils.js';
7
+ import { tmpdir } from 'os';
8
+ vi.mock('../../config/manager.js');
9
+ vi.mock('../display.js', () => ({
10
+ DisplayManager: {
11
+ getInstance: () => ({
12
+ log: vi.fn(),
13
+ }),
14
+ },
15
+ }));
16
+ describe('GwsSync', () => {
17
+ let mockDestDir;
18
+ let mockHashesFile;
19
+ const mockSourceDir = path.join(process.cwd(), 'gws-skills', 'skills');
20
+ beforeEach(async () => {
21
+ vi.resetAllMocks();
22
+ // Create a truly temporary directory for this test run
23
+ mockDestDir = path.join(tmpdir(), `morpheus-test-skills-${Date.now()}-${Math.random().toString(36).slice(2)}`);
24
+ mockHashesFile = path.join(mockDestDir, '.gws-hashes.json');
25
+ vi.mocked(ConfigManager.getInstance).mockReturnValue({
26
+ getGwsConfig: () => ({ enabled: true }),
27
+ });
28
+ await fs.ensureDir(mockDestDir);
29
+ });
30
+ afterEach(async () => {
31
+ await fs.remove(mockDestDir);
32
+ });
33
+ it('should copy new skills if destination does not exist', async () => {
34
+ if (!(await fs.pathExists(mockSourceDir))) {
35
+ console.warn('Skipping test: gws-skills/skills not found');
36
+ return;
37
+ }
38
+ await syncGwsSkills(mockDestDir);
39
+ const skills = await fs.readdir(mockSourceDir);
40
+ if (skills.length > 0) {
41
+ const firstSkill = skills[0];
42
+ expect(await fs.pathExists(path.join(mockDestDir, firstSkill, 'SKILL.md'))).toBe(true);
43
+ const metadata = await fs.readJson(mockHashesFile);
44
+ expect(metadata.skills[firstSkill]).toBeDefined();
45
+ }
46
+ });
47
+ it('should not overwrite customized skills', async () => {
48
+ if (!(await fs.pathExists(mockSourceDir)))
49
+ return;
50
+ const skills = await fs.readdir(mockSourceDir);
51
+ if (skills.length === 0)
52
+ return;
53
+ const skillName = skills[0];
54
+ const destPath = path.join(mockDestDir, skillName, 'SKILL.md');
55
+ // 1. Initial sync to set baseline
56
+ await syncGwsSkills(mockDestDir);
57
+ const originalMetadata = await fs.readJson(mockHashesFile);
58
+ const originalHash = originalMetadata.skills[skillName];
59
+ // 2. User modifies the file
60
+ await fs.writeFile(destPath, 'USER CUSTOMIZATION');
61
+ const customHash = calculateMd5('USER CUSTOMIZATION');
62
+ expect(customHash).not.toBe(originalHash);
63
+ // 3. Sync again
64
+ await syncGwsSkills(mockDestDir);
65
+ // 4. Verify file was preserved
66
+ const content = await fs.readFile(destPath, 'utf-8');
67
+ expect(content).toBe('USER CUSTOMIZATION');
68
+ });
69
+ });