morpheus-cli 0.9.24 → 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 (50) hide show
  1. package/dist/channels/telegram.js +165 -120
  2. package/dist/cli/commands/restart.js +12 -0
  3. package/dist/config/manager.js +34 -2
  4. package/dist/config/paths.js +2 -0
  5. package/dist/config/schemas.js +6 -0
  6. package/dist/runtime/__tests__/gws-sync.test.js +69 -0
  7. package/dist/runtime/gws-sync.js +102 -0
  8. package/dist/runtime/hash-utils.js +15 -0
  9. package/dist/runtime/hot-reload.js +5 -1
  10. package/dist/runtime/oracle.js +1 -0
  11. package/dist/runtime/scaffold.js +4 -0
  12. package/dist/runtime/skills/loader.js +7 -52
  13. package/dist/runtime/skills/registry.js +5 -1
  14. package/dist/runtime/skills/schema.js +1 -1
  15. package/dist/runtime/skills/tool.js +8 -1
  16. package/dist/runtime/subagents/apoc.js +154 -1
  17. package/dist/runtime/subagents/devkit-instrument.js +13 -0
  18. package/dist/types/config.js +3 -0
  19. package/dist/ui/assets/{AuditDashboard-CfYKdOEt.js → AuditDashboard-tH9QZTl4.js} +1 -1
  20. package/dist/ui/assets/{Chat-CYev7-CJ.js → Chat-Cd0uYF8g.js} +1 -1
  21. package/dist/ui/assets/{Chronos-5KR8aZud.js → Chronos-CWwHYdBl.js} +1 -1
  22. package/dist/ui/assets/{ConfirmationModal-NFwIYI7B.js → ConfirmationModal-CxvFe-We.js} +1 -1
  23. package/dist/ui/assets/{Dashboard-hsjB56la.js → Dashboard-CNNMxl53.js} +1 -1
  24. package/dist/ui/assets/{DeleteConfirmationModal-BfV370Vv.js → DeleteConfirmationModal-AqGQSh1X.js} +1 -1
  25. package/dist/ui/assets/{Documents-BNo2tMfG.js → Documents-BfRYOK88.js} +1 -1
  26. package/dist/ui/assets/{Logs-1hBpMPZE.js → Logs-DhFo4cio.js} +1 -1
  27. package/dist/ui/assets/{MCPManager-CvPRHn4C.js → MCPManager-BMhxbhni.js} +1 -1
  28. package/dist/ui/assets/{ModelPricing-BbwJFdz4.js → ModelPricing-Dvl0R_HR.js} +1 -1
  29. package/dist/ui/assets/{Notifications-C_MA51Gf.js → Notifications-CawvBid4.js} +1 -1
  30. package/dist/ui/assets/{SatiMemories-Cd9xn98_.js → SatiMemories-yyVrJGdc.js} +1 -1
  31. package/dist/ui/assets/{SessionAudit-BTABenGk.js → SessionAudit-joq0ntdJ.js} +1 -1
  32. package/dist/ui/assets/{Settings-DRVx4ICA.js → Settings-B6SMPn41.js} +7 -7
  33. package/dist/ui/assets/{Skills-DS9p1-S8.js → Skills-B5yhTHyn.js} +1 -1
  34. package/dist/ui/assets/{Smiths-CMCZaAF_.js → Smiths-Dug63YED.js} +1 -1
  35. package/dist/ui/assets/{Tasks-Cvt4sTcs.js → Tasks-D8HPLkg0.js} +1 -1
  36. package/dist/ui/assets/{TrinityDatabases-qhSUMeCw.js → TrinityDatabases-D0qEKmwJ.js} +1 -1
  37. package/dist/ui/assets/{UsageStats-Cy9HKYOp.js → UsageStats-CHWALN70.js} +1 -1
  38. package/dist/ui/assets/{WebhookManager-ByqkTyqs.js → WebhookManager-T2ef90p8.js} +1 -1
  39. package/dist/ui/assets/{agents-svEaAPka.js → agents-BVnfnJ1X.js} +1 -1
  40. package/dist/ui/assets/{audit-gxRPR5Jb.js → audit-BErc_ye8.js} +1 -1
  41. package/dist/ui/assets/{chronos-ZrBE4yA4.js → chronos-CAv__H3B.js} +1 -1
  42. package/dist/ui/assets/{config-B1i6Xxwk.js → config-CPFW7PTY.js} +1 -1
  43. package/dist/ui/assets/{index-DyKlGDg1.js → index-BvsF1a9j.js} +2 -2
  44. package/dist/ui/assets/{mcp-DSddQR1h.js → mcp-BaHwY4DW.js} +1 -1
  45. package/dist/ui/assets/{skills-DIuMjpPF.js → skills-lbjIRO8d.js} +1 -1
  46. package/dist/ui/assets/{stats-CxlRAO2g.js → stats-C8KAfpHO.js} +1 -1
  47. package/dist/ui/assets/{useCurrency-BkHiWfcT.js → useCurrency-Ch0lsvGj.js} +1 -1
  48. package/dist/ui/index.html +1 -1
  49. package/dist/ui/sw.js +1 -1
  50. package/package.json +1 -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';
@@ -87,6 +88,17 @@ export const restartCommand = new Command('restart')
87
88
  if (options.ui) {
88
89
  display.log(chalk.blue(`Web UI enabled to port ${options.port}`), { source: 'Zaion' });
89
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
+ }
90
102
  // Initialize Oracle
91
103
  const oracle = new Oracle(config);
92
104
  try {
@@ -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),
@@ -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
+ });
@@ -0,0 +1,102 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { PATHS } from '../config/paths.js';
4
+ import { ConfigManager } from '../config/manager.js';
5
+ import { calculateFileMd5 } from './hash-utils.js';
6
+ import { DisplayManager } from './display.js';
7
+ import chalk from 'chalk';
8
+ import { execSync } from 'child_process';
9
+ /**
10
+ * Checks if a binary is available in the system PATH.
11
+ */
12
+ function isBinaryAvailable(name) {
13
+ try {
14
+ const command = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
15
+ execSync(command, { stdio: 'ignore' });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * Synchronizes built-in Google Workspace skills to the user's skills directory.
24
+ * Uses MD5 hashes to avoid overwriting user customizations.
25
+ */
26
+ export async function syncGwsSkills(destOverride) {
27
+ const config = ConfigManager.getInstance().getGwsConfig();
28
+ if (config.enabled === false)
29
+ return;
30
+ const display = DisplayManager.getInstance();
31
+ const sourceDir = path.join(process.cwd(), 'gws-skills', 'skills');
32
+ const destDir = destOverride ?? PATHS.skills;
33
+ const hashesFile = path.join(destDir, '.gws-hashes.json');
34
+ // Check if gws binary is available
35
+ if (!isBinaryAvailable('gws')) {
36
+ display.log(`⚠️ Google Workspace CLI (gws) not found in system PATH. GWS skills will not function.`, { source: 'GwsSync', level: 'warning' });
37
+ }
38
+ // Validate Service Account JSON if provided
39
+ if (config.service_account_json) {
40
+ if (!(await fs.pathExists(config.service_account_json))) {
41
+ display.log(`⚠️ Google Workspace Service Account JSON not found at: ${chalk.yellow(config.service_account_json)}. GWS tools may fail to authenticate.`, { source: 'GwsSync', level: 'warning' });
42
+ }
43
+ }
44
+ if (!(await fs.pathExists(sourceDir))) {
45
+ // Silent skip if source doesn't exist (e.g. in some production environments)
46
+ return;
47
+ }
48
+ try {
49
+ let metadata = { skills: {}, last_sync: new Date().toISOString() };
50
+ if (await fs.pathExists(hashesFile)) {
51
+ metadata = await fs.readJson(hashesFile);
52
+ }
53
+ const builtInSkills = await fs.readdir(sourceDir);
54
+ let newCount = 0;
55
+ let updatedCount = 0;
56
+ let skippedCount = 0;
57
+ for (const skillName of builtInSkills) {
58
+ const skillSourcePath = path.join(sourceDir, skillName, 'SKILL.md');
59
+ const skillDestPath = path.join(destDir, skillName, 'SKILL.md');
60
+ if (!(await fs.pathExists(skillSourcePath)))
61
+ continue;
62
+ const sourceHash = await calculateFileMd5(skillSourcePath);
63
+ if (!(await fs.pathExists(skillDestPath))) {
64
+ // New skill
65
+ await fs.ensureDir(path.dirname(skillDestPath));
66
+ await fs.copy(skillSourcePath, skillDestPath);
67
+ metadata.skills[skillName] = sourceHash;
68
+ newCount++;
69
+ }
70
+ else {
71
+ // Existing skill - check for customization
72
+ const destHash = await calculateFileMd5(skillDestPath);
73
+ const lastKnownHash = metadata.skills[skillName];
74
+ if (destHash === sourceHash) {
75
+ // Already up to date
76
+ continue;
77
+ }
78
+ if (destHash === lastKnownHash) {
79
+ // Unmodified default, update to latest
80
+ await fs.copy(skillSourcePath, skillDestPath);
81
+ metadata.skills[skillName] = sourceHash;
82
+ updatedCount++;
83
+ }
84
+ else {
85
+ // User modified or unknown state, preserve
86
+ skippedCount++;
87
+ }
88
+ }
89
+ }
90
+ metadata.last_sync = new Date().toISOString();
91
+ await fs.writeJson(hashesFile, metadata, { spaces: 2 });
92
+ if (newCount > 0 || updatedCount > 0) {
93
+ display.log(`🔧 Google Workspace skills initialized: ${chalk.green(newCount)} new, ${chalk.blue(updatedCount)} updated${skippedCount > 0 ? `, ${chalk.yellow(skippedCount)} customized (skipped)` : ''}`, { source: 'GwsSync', level: 'info' });
94
+ }
95
+ }
96
+ catch (error) {
97
+ display.log(`Failed to sync Google Workspace skills: ${error instanceof Error ? error.message : String(error)}`, {
98
+ source: 'GwsSync',
99
+ level: 'error',
100
+ });
101
+ }
102
+ }
@@ -0,0 +1,15 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs-extra';
3
+ /**
4
+ * Calculates the MD5 hash of a string.
5
+ */
6
+ export function calculateMd5(content) {
7
+ return crypto.createHash('md5').update(content).digest('hex');
8
+ }
9
+ /**
10
+ * Calculates the MD5 hash of a file.
11
+ */
12
+ export async function calculateFileMd5(filePath) {
13
+ const content = await fs.readFile(filePath, 'utf-8');
14
+ return calculateMd5(content);
15
+ }
@@ -8,6 +8,7 @@
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { DisplayManager } from './display.js';
10
10
  import { SubagentRegistry } from './subagents/registry.js';
11
+ import { SkillRegistry } from './skills/index.js';
11
12
  let currentOracle = null;
12
13
  /**
13
14
  * Register the current Oracle instance for hot-reload.
@@ -36,7 +37,10 @@ export async function hotReloadConfig() {
36
37
  // 1. Reload configuration from disk
37
38
  await ConfigManager.getInstance().load();
38
39
  display.log('Configuration reloaded from disk', { source: 'HotReload', level: 'info' });
39
- // 2. Reinitialize Oracle if it exists
40
+ // 2. Reload skills
41
+ await SkillRegistry.getInstance().reload();
42
+ display.log('Skills reloaded from disk', { source: 'HotReload', level: 'info' });
43
+ // 3. Reinitialize Oracle if it exists
40
44
  if (currentOracle && typeof currentOracle.reinitialize === 'function') {
41
45
  await currentOracle.reinitialize();
42
46
  reinitialized.push('Oracle');