morpheus-cli 0.4.14 → 0.5.0

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 (38) hide show
  1. package/README.md +275 -1116
  2. package/dist/channels/telegram.js +210 -73
  3. package/dist/cli/commands/doctor.js +34 -0
  4. package/dist/cli/commands/init.js +128 -0
  5. package/dist/cli/commands/restart.js +17 -0
  6. package/dist/cli/commands/start.js +15 -0
  7. package/dist/config/manager.js +51 -0
  8. package/dist/config/schemas.js +7 -0
  9. package/dist/devkit/tools/network.js +1 -1
  10. package/dist/http/api.js +177 -10
  11. package/dist/runtime/apoc.js +139 -32
  12. package/dist/runtime/memory/sati/repository.js +30 -2
  13. package/dist/runtime/memory/sati/service.js +46 -15
  14. package/dist/runtime/memory/sati/system-prompts.js +71 -29
  15. package/dist/runtime/memory/sqlite.js +24 -0
  16. package/dist/runtime/neo.js +134 -0
  17. package/dist/runtime/oracle.js +244 -133
  18. package/dist/runtime/providers/factory.js +1 -12
  19. package/dist/runtime/tasks/context.js +53 -0
  20. package/dist/runtime/tasks/dispatcher.js +70 -0
  21. package/dist/runtime/tasks/notifier.js +68 -0
  22. package/dist/runtime/tasks/repository.js +370 -0
  23. package/dist/runtime/tasks/types.js +1 -0
  24. package/dist/runtime/tasks/worker.js +96 -0
  25. package/dist/runtime/tools/apoc-tool.js +61 -8
  26. package/dist/runtime/tools/delegation-guard.js +29 -0
  27. package/dist/runtime/tools/index.js +1 -0
  28. package/dist/runtime/tools/neo-tool.js +99 -0
  29. package/dist/runtime/tools/task-query-tool.js +76 -0
  30. package/dist/runtime/webhooks/dispatcher.js +10 -19
  31. package/dist/types/config.js +10 -0
  32. package/dist/ui/assets/index-20lLB1sM.js +112 -0
  33. package/dist/ui/assets/index-BJ56bRfs.css +1 -0
  34. package/dist/ui/index.html +2 -2
  35. package/dist/ui/sw.js +1 -1
  36. package/package.json +1 -1
  37. package/dist/ui/assets/index-LemKVRjC.js +0 -112
  38. package/dist/ui/assets/index-TCQ7VNYO.css +0 -1
@@ -13,30 +13,101 @@ import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
13
13
  import { SatiRepository } from '../runtime/memory/sati/repository.js';
14
14
  import { MCPManager } from '../config/mcp-manager.js';
15
15
  import { Construtor } from '../runtime/tools/factory.js';
16
+ function escapeHtml(text) {
17
+ return text
18
+ .replace(/&/g, '&')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;');
21
+ }
22
+ /** Strips HTML tags and unescapes entities for plain-text Telegram fallback. */
23
+ function stripHtmlTags(html) {
24
+ return html
25
+ .replace(/<br\s*\/?>/gi, '\n')
26
+ .replace(/<\/?(p|div|li|tr)[^>]*>/gi, '\n')
27
+ .replace(/<[^>]+>/g, '')
28
+ .replace(/&amp;/g, '&')
29
+ .replace(/&lt;/g, '<')
30
+ .replace(/&gt;/g, '>')
31
+ .replace(/&quot;/g, '"')
32
+ .replace(/&#39;/g, "'")
33
+ .trim();
34
+ }
16
35
  /**
17
- * Converts standard Markdown (as produced by LLMs) to Telegram MarkdownV2.
18
- * Unsupported tags (e.g. tables) have their special chars escaped so they
19
- * render as plain text instead of breaking the parse.
20
- * Truncates to Telegram's 4096-char hard limit.
21
- * Use for dynamic LLM/Oracle output.
36
+ * Splits an HTML string into chunks maxLen that never break inside an HTML tag.
37
+ * Prefers splitting at paragraph (double newline) or line boundaries.
22
38
  */
23
- // Cached dynamic import of telegram-markdown-v2 (ESM-safe, loaded once on first use)
24
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
- let _convertFn = null;
26
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
- async function getConvert() {
28
- if (!_convertFn) {
29
- const mod = await import('telegram-markdown-v2');
30
- _convertFn = mod.convert;
39
+ function splitHtmlChunks(html, maxLen = 4096) {
40
+ if (html.length <= maxLen)
41
+ return [html];
42
+ const chunks = [];
43
+ let remaining = html.trim();
44
+ while (remaining.length > maxLen) {
45
+ let splitAt = -1;
46
+ for (const sep of ['\n\n', '\n', ' ']) {
47
+ const pos = remaining.lastIndexOf(sep, maxLen - 1);
48
+ if (pos < maxLen / 4)
49
+ continue; // avoid tiny first chunks
50
+ // Confirm position is not inside an HTML tag
51
+ const before = remaining.slice(0, pos);
52
+ const lastOpen = before.lastIndexOf('<');
53
+ const lastClose = before.lastIndexOf('>');
54
+ if (lastOpen > lastClose)
55
+ continue; // inside a tag — try next separator
56
+ splitAt = pos + sep.length;
57
+ break;
58
+ }
59
+ if (splitAt <= 0) {
60
+ // Fallback: split right after the last closing '>' before maxLen
61
+ const closing = remaining.lastIndexOf('>', maxLen - 1);
62
+ splitAt = closing > 0 ? closing + 1 : maxLen;
63
+ }
64
+ const chunk = remaining.slice(0, splitAt).trim();
65
+ if (chunk)
66
+ chunks.push(chunk);
67
+ remaining = remaining.slice(splitAt).trim();
31
68
  }
32
- return _convertFn;
69
+ if (remaining)
70
+ chunks.push(remaining);
71
+ return chunks.filter(Boolean);
33
72
  }
34
- async function toMd(text) {
35
- const MAX = 4096;
36
- const convert = await getConvert();
37
- const converted = convert(text, 'escape');
38
- const safe = converted.length > MAX ? converted.slice(0, MAX - 3) + '\\.\\.\\.' : converted;
39
- return { text: safe, parse_mode: 'MarkdownV2' };
73
+ async function toTelegramRichText(text) {
74
+ let source = String(text ?? '').replace(/\r\n/g, '\n');
75
+ const uuidRegex = /\b([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})\b/g;
76
+ const codeBlocks = [];
77
+ source = source.replace(/```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```/g, (_m, _lang, code) => {
78
+ const idx = codeBlocks.push(`<pre><code>${escapeHtml(String(code).trimEnd())}</code></pre>`) - 1;
79
+ return `@@CODEBLOCK_${idx}@@`;
80
+ });
81
+ const inlineCodes = [];
82
+ source = source.replace(/`([^`\n]+)`/g, (_m, code) => {
83
+ const idx = inlineCodes.push(`<code>${escapeHtml(code)}</code>`) - 1;
84
+ return `@@INLINECODE_${idx}@@`;
85
+ });
86
+ const links = [];
87
+ source = source.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
88
+ const safeUrl = String(url).replace(/"/g, '&quot;');
89
+ const idx = links.push(`<a href="${safeUrl}">${escapeHtml(label)}</a>`) - 1;
90
+ return `@@LINK_${idx}@@`;
91
+ });
92
+ // Markdown bullets become visible bullets in Telegram HTML mode.
93
+ source = source.replace(/^(\s*)[-*]\s+/gm, '$1• ');
94
+ // Escape user/model content before reinserting HTML tags.
95
+ source = escapeHtml(source);
96
+ // Headings -> bold lines
97
+ source = source.replace(/^#{1,6}\s+(.+)$/gm, (_m, title) => `<b>${title.trim()}</b>`);
98
+ // Bold
99
+ source = source.replace(/\*\*([\s\S]+?)\*\*/g, '<b>$1</b>');
100
+ source = source.replace(/__([\s\S]+?)__/g, '<b>$1</b>');
101
+ // Italic (conservative)
102
+ source = source.replace(/(^|[\s(])\*([^*\n]+)\*(?=[$\s).,!?:;])/gm, '$1<i>$2</i>');
103
+ source = source.replace(/(^|[\s(])_([^_\n]+)_(?=[$\s).,!?:;])/gm, '$1<i>$2</i>');
104
+ // Make task/session IDs easier to copy in Telegram.
105
+ source = source.replace(uuidRegex, '<code>$1</code>');
106
+ // Restore placeholders
107
+ source = source.replace(/@@CODEBLOCK_(\d+)@@/g, (_m, idx) => codeBlocks[Number(idx)] || '');
108
+ source = source.replace(/@@INLINECODE_(\d+)@@/g, (_m, idx) => inlineCodes[Number(idx)] || '');
109
+ source = source.replace(/@@LINK_(\d+)@@/g, (_m, idx) => links[Number(idx)] || '');
110
+ return { chunks: splitHtmlChunks(source.trim()), parse_mode: 'HTML' };
40
111
  }
41
112
  /**
42
113
  * Escapes special characters in a plain string segment so it's safe to embed
@@ -78,7 +149,7 @@ export class TelegramAdapter {
78
149
  /stats \\- Show token usage statistics
79
150
  /help \\- Show available commands
80
151
  /zaion \\- Show system configurations
81
- /sati <qnt> \\- Show specific memories
152
+ /sati qnt \\- Show specific memories
82
153
  /newsession \\- Archive current session and start fresh
83
154
  /sessions \\- List all sessions with titles and switch between them
84
155
  /restart \\- Restart the Morpheus agent
@@ -123,14 +194,26 @@ export class TelegramAdapter {
123
194
  try {
124
195
  // Send "typing" status
125
196
  await ctx.sendChatAction('typing');
197
+ const sessionId = await this.history.getCurrentSessionOrCreate();
198
+ await this.oracle.setSessionId(sessionId);
126
199
  // Process with Agent
127
- const response = await this.oracle.chat(text);
200
+ const response = await this.oracle.chat(text, undefined, false, {
201
+ origin_channel: 'telegram',
202
+ session_id: sessionId,
203
+ origin_message_id: String(ctx.message.message_id),
204
+ origin_user_id: userId,
205
+ });
128
206
  if (response) {
129
- try {
130
- await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
131
- }
132
- catch {
133
- await ctx.reply(response);
207
+ const rich = await toTelegramRichText(response);
208
+ for (const chunk of rich.chunks) {
209
+ try {
210
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
211
+ }
212
+ catch {
213
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
214
+ if (plain)
215
+ await ctx.reply(plain);
216
+ }
134
217
  }
135
218
  this.display.log(`Responded to @${user}: ${response}`, { source: 'Telegram' });
136
219
  }
@@ -200,8 +283,15 @@ export class TelegramAdapter {
200
283
  // So I should treat 'text' as if it was a text message.
201
284
  await ctx.reply(`🎤 Transcription: "${text}"`);
202
285
  await ctx.sendChatAction('typing');
286
+ const sessionId = await this.history.getCurrentSessionOrCreate();
287
+ await this.oracle.setSessionId(sessionId);
203
288
  // Process with Agent
204
- const response = await this.oracle.chat(text, usage, true);
289
+ const response = await this.oracle.chat(text, usage, true, {
290
+ origin_channel: 'telegram',
291
+ session_id: sessionId,
292
+ origin_message_id: String(ctx.message.message_id),
293
+ origin_user_id: userId,
294
+ });
205
295
  // if (listeningMsg) {
206
296
  // try {
207
297
  // await ctx.telegram.deleteMessage(ctx.chat.id, listeningMsg.message_id);
@@ -210,11 +300,16 @@ export class TelegramAdapter {
210
300
  // }
211
301
  // }
212
302
  if (response) {
213
- try {
214
- await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
215
- }
216
- catch {
217
- await ctx.reply(response);
303
+ const rich = await toTelegramRichText(response);
304
+ for (const chunk of rich.chunks) {
305
+ try {
306
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
307
+ }
308
+ catch {
309
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
310
+ if (plain)
311
+ await ctx.reply(plain);
312
+ }
218
313
  }
219
314
  this.display.log(`Responded to @${user} (via audio)`, { source: 'Telegram' });
220
315
  }
@@ -405,18 +500,10 @@ export class TelegramAdapter {
405
500
  await fs.writeFile(filePath, buffer);
406
501
  return filePath;
407
502
  }
408
- /**
409
- * Escapes a string for Telegram MarkdownV2 format.
410
- * All special characters outside code spans must be escaped with a backslash.
411
- */
412
- escapeMarkdownV2(text) {
413
- // Characters that must be escaped in MarkdownV2 outside of code/pre blocks
414
- return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
415
- }
416
503
  /**
417
504
  * Sends a proactive message to all allowed Telegram users.
418
505
  * Used by the webhook notification system to push results.
419
- * Tries plain text first to avoid Markdown parse errors from LLM output.
506
+ * Uses Telegram HTML parse mode for richer formatting, with plain-text fallback.
420
507
  */
421
508
  async sendMessage(text) {
422
509
  if (!this.isConnected || !this.bot) {
@@ -428,22 +515,39 @@ export class TelegramAdapter {
428
515
  this.display.log('No allowed Telegram users configured — skipping notification.', { source: 'Telegram', level: 'warning' });
429
516
  return;
430
517
  }
431
- // toMd() already truncates to 4096 chars (Telegram's hard limit)
432
- const { text: mdText, parse_mode } = await toMd(text);
518
+ const rich = await toTelegramRichText(text);
433
519
  for (const userId of allowedUsers) {
520
+ for (const chunk of rich.chunks) {
521
+ try {
522
+ await this.bot.telegram.sendMessage(userId, chunk, { parse_mode: rich.parse_mode });
523
+ }
524
+ catch {
525
+ try {
526
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
527
+ if (plain)
528
+ await this.bot.telegram.sendMessage(userId, plain);
529
+ }
530
+ catch (err) {
531
+ this.display.log(`Failed to send message chunk to Telegram user ${userId}: ${err.message}`, { source: 'Telegram', level: 'error' });
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+ async sendMessageToUser(userId, text) {
538
+ if (!this.isConnected || !this.bot) {
539
+ this.display.log('Cannot send direct message: Telegram bot not connected.', { source: 'Telegram', level: 'warning' });
540
+ return;
541
+ }
542
+ const rich = await toTelegramRichText(text);
543
+ for (const chunk of rich.chunks) {
434
544
  try {
435
- await this.bot.telegram.sendMessage(userId, mdText, { parse_mode });
545
+ await this.bot.telegram.sendMessage(userId, chunk, { parse_mode: rich.parse_mode });
436
546
  }
437
547
  catch {
438
- // Fallback to plain text if MarkdownV2 conversion still fails
439
- try {
440
- const MAX_LEN = 4096;
441
- const plain = text.length > MAX_LEN ? text.slice(0, MAX_LEN - 3) + '...' : text;
548
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
549
+ if (plain)
442
550
  await this.bot.telegram.sendMessage(userId, plain);
443
- }
444
- catch (err) {
445
- this.display.log(`Failed to send message to Telegram user ${userId}: ${err.message}`, { source: 'Telegram', level: 'error' });
446
- }
447
551
  }
448
552
  }
449
553
  }
@@ -622,7 +726,7 @@ How can I assist you today?`;
622
726
  response += `✅ Node\\.js: ${escMd(nodeVersion)}\n`;
623
727
  }
624
728
  else {
625
- response += `❌ Node\\.js: ${escMd(nodeVersion)} \\(Required: >=18\\)\n`;
729
+ response += `❌ Node\\.js: ${escMd(nodeVersion)} \\(Required: \\>\\=18\\)\n`;
626
730
  }
627
731
  if (config) {
628
732
  response += '✅ Configuration: Valid\n';
@@ -672,6 +776,17 @@ How can I assist you today?`;
672
776
  response += `❌ Apoc API key missing \\(${escMd(apocProvider)}\\)\n`;
673
777
  }
674
778
  }
779
+ // Neo
780
+ const neo = config.neo;
781
+ const neoProvider = neo?.provider || llmProvider;
782
+ if (neoProvider && neoProvider !== 'ollama') {
783
+ if (hasApiKey(neoProvider, neo?.api_key ?? config.llm?.api_key)) {
784
+ response += `✅ Neo API key \\(${escMd(neoProvider)}\\)\n`;
785
+ }
786
+ else {
787
+ response += `❌ Neo API key missing \\(${escMd(neoProvider)}\\)\n`;
788
+ }
789
+ }
675
790
  // Telegram token
676
791
  if (config.channels?.telegram?.enabled) {
677
792
  const hasTelegramToken = config.channels.telegram?.token || process.env.TELEGRAM_BOT_TOKEN;
@@ -755,11 +870,16 @@ How can I assist you today?`;
755
870
  Só faça isso agora.`;
756
871
  let response = await this.oracle.chat(prompt);
757
872
  if (response) {
758
- try {
759
- await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
760
- }
761
- catch {
762
- await ctx.reply(response);
873
+ const rich = await toTelegramRichText(response);
874
+ for (const chunk of rich.chunks) {
875
+ try {
876
+ await ctx.reply(chunk, { parse_mode: rich.parse_mode });
877
+ }
878
+ catch {
879
+ const plain = stripHtmlTags(chunk).slice(0, 4096);
880
+ if (plain)
881
+ await ctx.reply(plain);
882
+ }
763
883
  }
764
884
  }
765
885
  // await ctx.reply(`Command not recognized. Type /help to see available commands.`);
@@ -807,6 +927,22 @@ How can I assist you today?`;
807
927
  response += `\\- Inherits Oracle config\n`;
808
928
  }
809
929
  response += '\n';
930
+ // Neo config (falls back to llm if not set)
931
+ const neo = config.neo;
932
+ response += `*Neo \\(MCP \\+ Internal Tools\\):*\n`;
933
+ if (neo?.provider) {
934
+ response += `\\- Provider: ${escMd(neo.provider)}\n`;
935
+ response += `\\- Model: ${escMd(neo.model || config.llm.model)}\n`;
936
+ response += `\\- Temperature: ${escMd(neo.temperature ?? 0.2)}\n`;
937
+ response += `\\- Context Window: ${escMd(neo.context_window ?? config.llm.context_window ?? 100)}\n`;
938
+ if (neo.max_tokens !== undefined) {
939
+ response += `\\- Max Tokens: ${escMd(neo.max_tokens)}\n`;
940
+ }
941
+ }
942
+ else {
943
+ response += `\\- Inherits Oracle config\n`;
944
+ }
945
+ response += '\n';
810
946
  response += `*Channels:*\n`;
811
947
  response += `\\- Telegram Enabled: ${escMd(config.channels.telegram.enabled)}\n`;
812
948
  response += `\\- Discord Enabled: ${escMd(config.channels.discord.enabled)}\n\n`;
@@ -828,28 +964,29 @@ How can I assist you today?`;
828
964
  }
829
965
  }
830
966
  try {
831
- // Usar o repositório SATI para obter memórias de longo prazo
832
967
  const repository = SatiRepository.getInstance();
833
968
  const memories = repository.getAllMemories();
834
969
  if (memories.length === 0) {
835
- await ctx.reply(`No memories found.`);
970
+ await ctx.reply('No memories found.');
836
971
  return;
837
972
  }
838
- // Se nenhum limite for especificado, usar todas as memórias
839
- let selectedMemories = memories;
840
- if (limit !== null) {
841
- selectedMemories = memories.slice(0, Math.min(limit, memories.length));
842
- }
973
+ const selectedMemories = limit !== null
974
+ ? memories.slice(0, Math.min(limit, memories.length))
975
+ : memories;
843
976
  const countLabel = limit !== null
844
- ? `${escMd(selectedMemories.length)} SATI Memories \\(Showing first ${escMd(selectedMemories.length)}\\)`
845
- : `${escMd(selectedMemories.length)} SATI Memories`;
846
- let response = `*${countLabel}:*\n\n`;
977
+ ? `${selectedMemories.length} SATI Memories (showing first ${selectedMemories.length})`
978
+ : `${selectedMemories.length} SATI Memories`;
979
+ let html = `<b>${escapeHtml(countLabel)}:</b>\n\n`;
847
980
  for (const memory of selectedMemories) {
848
- // Limitar o tamanho do resumo para evitar mensagens muito longas
849
- const truncatedSummary = memory.summary.length > 200 ? memory.summary.substring(0, 200) + '...' : memory.summary;
850
- response += `*${escMd(memory.category)} \\(${escMd(memory.importance)}\\):* ${escMd(truncatedSummary)}\n\n`;
981
+ const summary = memory.summary.length > 200
982
+ ? memory.summary.substring(0, 200) + '...'
983
+ : memory.summary;
984
+ html += `<b>${escapeHtml(memory.category)} (${escapeHtml(memory.importance)}):</b> ${escapeHtml(summary)}\n\n`;
985
+ }
986
+ const chunks = splitHtmlChunks(html.trim());
987
+ for (const chunk of chunks) {
988
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
851
989
  }
852
- await ctx.reply(response, { parse_mode: 'MarkdownV2' });
853
990
  }
854
991
  catch (error) {
855
992
  await ctx.reply(`Failed to retrieve memories: ${error.message}`);
@@ -50,6 +50,8 @@ export const doctorCommand = new Command('doctor')
50
50
  // Check API keys availability for active providers
51
51
  const llmProvider = config.llm?.provider;
52
52
  const satiProvider = config.sati?.provider;
53
+ const apocProvider = config.apoc?.provider || llmProvider;
54
+ const neoProvider = config.neo?.provider || llmProvider;
53
55
  // Check LLM provider API key
54
56
  if (llmProvider && llmProvider !== 'ollama') {
55
57
  const hasLlmApiKey = config.llm?.api_key ||
@@ -80,6 +82,38 @@ export const doctorCommand = new Command('doctor')
80
82
  allPassed = false;
81
83
  }
82
84
  }
85
+ // Check Apoc provider API key
86
+ if (apocProvider && apocProvider !== 'ollama') {
87
+ const hasApocApiKey = config.apoc?.api_key ||
88
+ config.llm?.api_key ||
89
+ (apocProvider === 'openai' && process.env.OPENAI_API_KEY) ||
90
+ (apocProvider === 'anthropic' && process.env.ANTHROPIC_API_KEY) ||
91
+ (apocProvider === 'gemini' && process.env.GOOGLE_API_KEY) ||
92
+ (apocProvider === 'openrouter' && process.env.OPENROUTER_API_KEY);
93
+ if (hasApocApiKey) {
94
+ console.log(chalk.green('✓') + ` Apoc API key available for ${apocProvider}`);
95
+ }
96
+ else {
97
+ console.log(chalk.red('✗') + ` Apoc API key missing for ${apocProvider}. Either set in config or define environment variable.`);
98
+ allPassed = false;
99
+ }
100
+ }
101
+ // Check Neo provider API key
102
+ if (neoProvider && neoProvider !== 'ollama') {
103
+ const hasNeoApiKey = config.neo?.api_key ||
104
+ config.llm?.api_key ||
105
+ (neoProvider === 'openai' && process.env.OPENAI_API_KEY) ||
106
+ (neoProvider === 'anthropic' && process.env.ANTHROPIC_API_KEY) ||
107
+ (neoProvider === 'gemini' && process.env.GOOGLE_API_KEY) ||
108
+ (neoProvider === 'openrouter' && process.env.OPENROUTER_API_KEY);
109
+ if (hasNeoApiKey) {
110
+ console.log(chalk.green('✓') + ` Neo API key available for ${neoProvider}`);
111
+ }
112
+ else {
113
+ console.log(chalk.red('✗') + ` Neo API key missing for ${neoProvider}. Either set in config or define environment variable.`);
114
+ allPassed = false;
115
+ }
116
+ }
83
117
  // Check audio API key if enabled
84
118
  if (config.audio?.enabled && config.llm?.provider !== 'gemini') {
85
119
  const hasAudioApiKey = config.audio?.apiKey || process.env.GOOGLE_API_KEY;
@@ -208,6 +208,134 @@ export const initCommand = new Command('init')
208
208
  if (satiApiKey) {
209
209
  await configManager.set('sati.api_key', satiApiKey);
210
210
  }
211
+ // Neo (MCP + Internal Tools Agent) Configuration
212
+ display.log(chalk.blue('\nNeo (MCP + Internal Tools Agent) Configuration'));
213
+ const configureNeo = await select({
214
+ message: 'Configure Neo separately?',
215
+ choices: [
216
+ { name: 'No (Use Oracle provider/model defaults)', value: 'no' },
217
+ { name: 'Yes', value: 'yes' },
218
+ ],
219
+ default: currentConfig.neo ? 'yes' : 'no',
220
+ });
221
+ let neoProvider = provider;
222
+ let neoModel = model;
223
+ let neoTemperature = currentConfig.neo?.temperature ?? 0.2;
224
+ let neoContextWindow = currentConfig.neo?.context_window ?? Number(contextWindow);
225
+ let neoMaxTokens = currentConfig.neo?.max_tokens;
226
+ let neoApiKey = currentConfig.neo?.api_key || apiKey || currentConfig.llm.api_key;
227
+ let neoBaseUrl = provider === 'openrouter'
228
+ ? (currentConfig.neo?.base_url || currentConfig.llm.base_url || 'https://openrouter.ai/api/v1')
229
+ : undefined;
230
+ if (configureNeo === 'yes') {
231
+ neoProvider = await select({
232
+ message: 'Select Neo LLM Provider:',
233
+ choices: [
234
+ { name: 'OpenAI', value: 'openai' },
235
+ { name: 'Anthropic', value: 'anthropic' },
236
+ { name: 'OpenRouter', value: 'openrouter' },
237
+ { name: 'Ollama', value: 'ollama' },
238
+ { name: 'Google Gemini', value: 'gemini' },
239
+ ],
240
+ default: currentConfig.neo?.provider || provider,
241
+ });
242
+ let defaultNeoModel = 'gpt-3.5-turbo';
243
+ switch (neoProvider) {
244
+ case 'openai':
245
+ defaultNeoModel = 'gpt-4o';
246
+ break;
247
+ case 'anthropic':
248
+ defaultNeoModel = 'claude-3-5-sonnet-20240620';
249
+ break;
250
+ case 'openrouter':
251
+ defaultNeoModel = 'openrouter/auto';
252
+ break;
253
+ case 'ollama':
254
+ defaultNeoModel = 'llama3';
255
+ break;
256
+ case 'gemini':
257
+ defaultNeoModel = 'gemini-pro';
258
+ break;
259
+ }
260
+ if (neoProvider === currentConfig.neo?.provider) {
261
+ defaultNeoModel = currentConfig.neo?.model || defaultNeoModel;
262
+ }
263
+ neoModel = await input({
264
+ message: 'Enter Neo Model Name:',
265
+ default: defaultNeoModel,
266
+ });
267
+ const neoTemperatureInput = await input({
268
+ message: 'Neo Temperature (0-1):',
269
+ default: (currentConfig.neo?.temperature ?? 0.2).toString(),
270
+ validate: (val) => {
271
+ const n = Number(val);
272
+ return (!isNaN(n) && n >= 0 && n <= 1) || 'Must be a number between 0 and 1';
273
+ },
274
+ });
275
+ neoTemperature = Number(neoTemperatureInput);
276
+ const neoContextWindowInput = await input({
277
+ message: 'Neo Context Window (messages):',
278
+ default: (currentConfig.neo?.context_window ?? Number(contextWindow)).toString(),
279
+ validate: (val) => (!isNaN(Number(val)) && Number(val) > 0) || 'Must be a positive number',
280
+ });
281
+ neoContextWindow = Number(neoContextWindowInput);
282
+ const neoMaxTokensInput = await input({
283
+ message: 'Neo Max Tokens (optional, leave empty for model default):',
284
+ default: currentConfig.neo?.max_tokens?.toString() || '',
285
+ validate: (val) => {
286
+ if (val.trim() === '')
287
+ return true;
288
+ return (!isNaN(Number(val)) && Number(val) > 0) || 'Must be a positive number';
289
+ },
290
+ });
291
+ neoMaxTokens = neoMaxTokensInput.trim() === '' ? undefined : Number(neoMaxTokensInput);
292
+ if (neoProvider !== 'ollama') {
293
+ const hasExistingNeoKey = !!currentConfig.neo?.api_key || !!currentConfig.llm?.api_key;
294
+ let neoKeyMsg = hasExistingNeoKey
295
+ ? 'Enter Neo API Key (leave empty to preserve existing, or if using env vars):'
296
+ : 'Enter Neo API Key (leave empty if using env vars):';
297
+ if (neoProvider === 'openai') {
298
+ neoKeyMsg = `${neoKeyMsg} (Env vars: MORPHEUS_NEO_API_KEY / OPENAI_API_KEY)`;
299
+ }
300
+ else if (neoProvider === 'anthropic') {
301
+ neoKeyMsg = `${neoKeyMsg} (Env vars: MORPHEUS_NEO_API_KEY / ANTHROPIC_API_KEY)`;
302
+ }
303
+ else if (neoProvider === 'gemini') {
304
+ neoKeyMsg = `${neoKeyMsg} (Env vars: MORPHEUS_NEO_API_KEY / GOOGLE_API_KEY)`;
305
+ }
306
+ else if (neoProvider === 'openrouter') {
307
+ neoKeyMsg = `${neoKeyMsg} (Env vars: MORPHEUS_NEO_API_KEY / OPENROUTER_API_KEY)`;
308
+ }
309
+ const neoKeyInput = await password({ message: neoKeyMsg });
310
+ if (neoKeyInput) {
311
+ neoApiKey = neoKeyInput;
312
+ }
313
+ else {
314
+ neoApiKey = currentConfig.neo?.api_key || currentConfig.llm?.api_key;
315
+ }
316
+ }
317
+ if (neoProvider === 'openrouter') {
318
+ neoBaseUrl = await input({
319
+ message: 'Enter Neo OpenRouter Base URL:',
320
+ default: currentConfig.neo?.base_url || currentConfig.llm.base_url || 'https://openrouter.ai/api/v1',
321
+ });
322
+ }
323
+ else {
324
+ neoBaseUrl = undefined;
325
+ }
326
+ }
327
+ await configManager.save({
328
+ ...configManager.get(),
329
+ neo: {
330
+ provider: neoProvider,
331
+ model: neoModel,
332
+ temperature: neoTemperature,
333
+ context_window: neoContextWindow,
334
+ max_tokens: neoMaxTokens,
335
+ api_key: neoApiKey,
336
+ base_url: neoBaseUrl,
337
+ },
338
+ });
211
339
  // Audio Configuration
212
340
  const audioEnabled = await confirm({
213
341
  message: 'Enable Audio Transcription? (Requires Gemini)',
@@ -12,6 +12,10 @@ import { Oracle } from '../../runtime/oracle.js';
12
12
  import { ProviderError } from '../../runtime/errors.js';
13
13
  import { HttpServer } from '../../http/server.js';
14
14
  import { getVersion } from '../utils/version.js';
15
+ import { TaskWorker } from '../../runtime/tasks/worker.js';
16
+ import { TaskNotifier } from '../../runtime/tasks/notifier.js';
17
+ import { TaskDispatcher } from '../../runtime/tasks/dispatcher.js';
18
+ import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
15
19
  export const restartCommand = new Command('restart')
16
20
  .description('Restart the Morpheus agent')
17
21
  .option('--ui', 'Enable web UI', true)
@@ -96,6 +100,9 @@ export const restartCommand = new Command('restart')
96
100
  }
97
101
  const adapters = [];
98
102
  let httpServer;
103
+ const taskWorker = new TaskWorker();
104
+ const taskNotifier = new TaskNotifier();
105
+ const asyncTasksEnabled = config.runtime?.async_tasks?.enabled !== false;
99
106
  // Initialize Web UI
100
107
  if (options.ui && config.ui.enabled) {
101
108
  try {
@@ -114,6 +121,8 @@ export const restartCommand = new Command('restart')
114
121
  const telegram = new TelegramAdapter(oracle);
115
122
  try {
116
123
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
124
+ WebhookDispatcher.setTelegramAdapter(telegram);
125
+ TaskDispatcher.setTelegramAdapter(telegram);
117
126
  adapters.push(telegram);
118
127
  }
119
128
  catch (e) {
@@ -124,6 +133,10 @@ export const restartCommand = new Command('restart')
124
133
  display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'));
125
134
  }
126
135
  }
136
+ if (asyncTasksEnabled) {
137
+ taskWorker.start();
138
+ taskNotifier.start();
139
+ }
127
140
  // Handle graceful shutdown
128
141
  const shutdown = async (signal) => {
129
142
  display.stopSpinner();
@@ -134,6 +147,10 @@ export const restartCommand = new Command('restart')
134
147
  for (const adapter of adapters) {
135
148
  await adapter.disconnect();
136
149
  }
150
+ if (asyncTasksEnabled) {
151
+ taskWorker.stop();
152
+ taskNotifier.stop();
153
+ }
137
154
  await clearPid();
138
155
  process.exit(0);
139
156
  };