morpheus-cli 0.4.12 → 0.4.14

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.
@@ -5,7 +5,6 @@ import fs from 'fs-extra';
5
5
  import path from 'path';
6
6
  import os from 'os';
7
7
  import { spawn } from 'child_process';
8
- import { convert } from 'telegram-markdown-v2';
9
8
  import { ConfigManager } from '../config/manager.js';
10
9
  import { DisplayManager } from '../runtime/display.js';
11
10
  import { createTelephonist } from '../runtime/telephonist.js';
@@ -21,8 +20,20 @@ import { Construtor } from '../runtime/tools/factory.js';
21
20
  * Truncates to Telegram's 4096-char hard limit.
22
21
  * Use for dynamic LLM/Oracle output.
23
22
  */
24
- function toMd(text) {
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;
31
+ }
32
+ return _convertFn;
33
+ }
34
+ async function toMd(text) {
25
35
  const MAX = 4096;
36
+ const convert = await getConvert();
26
37
  const converted = convert(text, 'escape');
27
38
  const safe = converted.length > MAX ? converted.slice(0, MAX - 3) + '\\.\\.\\.' : converted;
28
39
  return { text: safe, parse_mode: 'MarkdownV2' };
@@ -116,7 +127,7 @@ export class TelegramAdapter {
116
127
  const response = await this.oracle.chat(text);
117
128
  if (response) {
118
129
  try {
119
- await ctx.reply(toMd(response).text, { parse_mode: 'MarkdownV2' });
130
+ await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
120
131
  }
121
132
  catch {
122
133
  await ctx.reply(response);
@@ -200,7 +211,7 @@ export class TelegramAdapter {
200
211
  // }
201
212
  if (response) {
202
213
  try {
203
- await ctx.reply(toMd(response).text, { parse_mode: 'MarkdownV2' });
214
+ await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
204
215
  }
205
216
  catch {
206
217
  await ctx.reply(response);
@@ -264,7 +275,7 @@ export class TelegramAdapter {
264
275
  const data = ctx.callbackQuery.data;
265
276
  const sessionId = data.replace('ask_archive_session_', '');
266
277
  // Fetch session title for better UX (optional, but nice) - for now just use ID
267
- await ctx.reply(`⚠️ **ARCHIVE SESSION?**\n\nAre you sure you want to archive session \`${sessionId}\`?\n\nIt will be moved to long-term memory (SATI) and removed from the active list. This action cannot be easily undone via Telegram.`, {
278
+ await ctx.reply(`⚠️ *ARCHIVE SESSION?*\n\nAre you sure you want to archive session \`${escMd(sessionId)}\`?\n\nIt will be moved to long\\-term memory \\(SATI\\) and removed from the active list\\. This action cannot be easily undone via Telegram\\.`, {
268
279
  parse_mode: 'MarkdownV2',
269
280
  reply_markup: {
270
281
  inline_keyboard: [
@@ -287,7 +298,7 @@ export class TelegramAdapter {
287
298
  if (ctx.updateType === 'callback_query') {
288
299
  ctx.deleteMessage().catch(() => { });
289
300
  }
290
- await ctx.reply(`✅ Session \`${sessionId}\` has been archived and moved to long-term memory.`, { parse_mode: 'MarkdownV2' });
301
+ await ctx.reply(`✅ Session \`${escMd(sessionId)}\` has been archived and moved to long\\-term memory\\.`, { parse_mode: 'MarkdownV2' });
291
302
  }
292
303
  catch (error) {
293
304
  await ctx.answerCbQuery(`Error archiving: ${error.message}`, { show_alert: true });
@@ -297,7 +308,7 @@ export class TelegramAdapter {
297
308
  this.bot.action(/^ask_delete_session_/, async (ctx) => {
298
309
  const data = ctx.callbackQuery.data;
299
310
  const sessionId = data.replace('ask_delete_session_', '');
300
- await ctx.reply(`🚫 **DELETE SESSION?**\n\nAre you sure you want to PERMANENTLY DELETE session \`${sessionId}\`?\n\nThis action is **IRREVERSIBLE**. All data will be lost.`, {
311
+ await ctx.reply(`🚫 *DELETE SESSION?*\n\nAre you sure you want to PERMANENTLY DELETE session \`${escMd(sessionId)}\`?\n\nThis action is *IRREVERSIBLE*\\. All data will be lost\\.`, {
301
312
  parse_mode: 'MarkdownV2',
302
313
  reply_markup: {
303
314
  inline_keyboard: [
@@ -320,7 +331,7 @@ export class TelegramAdapter {
320
331
  if (ctx.updateType === 'callback_query') {
321
332
  ctx.deleteMessage().catch(() => { });
322
333
  }
323
- await ctx.reply(`🗑️ Session \`${sessionId}\` has been permanently deleted.`, { parse_mode: 'MarkdownV2' });
334
+ await ctx.reply(`🗑️ Session \`${escMd(sessionId)}\` has been permanently deleted\\.`, { parse_mode: 'MarkdownV2' });
324
335
  }
325
336
  catch (error) {
326
337
  await ctx.answerCbQuery(`Error deleting: ${error.message}`, { show_alert: true });
@@ -418,7 +429,7 @@ export class TelegramAdapter {
418
429
  return;
419
430
  }
420
431
  // toMd() already truncates to 4096 chars (Telegram's hard limit)
421
- const { text: mdText, parse_mode } = toMd(text);
432
+ const { text: mdText, parse_mode } = await toMd(text);
422
433
  for (const userId of allowedUsers) {
423
434
  try {
424
435
  await this.bot.telegram.sendMessage(userId, mdText, { parse_mode });
@@ -506,7 +517,7 @@ export class TelegramAdapter {
506
517
  }
507
518
  async handleNewSessionCommand(ctx, user) {
508
519
  try {
509
- await ctx.reply("Are you ready to start a new session? Please confirm.", {
520
+ await ctx.reply("Are you ready to start a new session\\? Please confirm\\.", {
510
521
  parse_mode: 'MarkdownV2', reply_markup: {
511
522
  inline_keyboard: [
512
523
  [{ text: 'Yes, start new session', callback_data: 'confirm_new_session' }, { text: 'No, cancel', callback_data: 'cancel_new_session' }]
@@ -533,7 +544,7 @@ export class TelegramAdapter {
533
544
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
534
545
  const sessions = await history.listSessions();
535
546
  if (sessions.length === 0) {
536
- await ctx.reply('No active or paused sessions found.', { parse_mode: 'MarkdownV2' });
547
+ await ctx.reply('No active or paused sessions found\\.', { parse_mode: 'MarkdownV2' });
537
548
  return;
538
549
  }
539
550
  let response = '*Sessions:*\n\n';
@@ -608,10 +619,10 @@ How can I assist you today?`;
608
619
  const nodeVersion = process.version;
609
620
  const majorVersion = parseInt(nodeVersion.replace('v', '').split('.')[0], 10);
610
621
  if (majorVersion >= 18) {
611
- response += '✅ Node.js: ' + nodeVersion + '\n';
622
+ response += `✅ Node\\.js: ${escMd(nodeVersion)}\n`;
612
623
  }
613
624
  else {
614
- response += '❌ Node.js: ' + nodeVersion + ' (Required: >=18)\n';
625
+ response += `❌ Node\\.js: ${escMd(nodeVersion)} \\(Required: >=18\\)\n`;
615
626
  }
616
627
  if (config) {
617
628
  response += '✅ Configuration: Valid\n';
@@ -633,10 +644,10 @@ How can I assist you today?`;
633
644
  const llmProvider = config.llm?.provider;
634
645
  if (llmProvider && llmProvider !== 'ollama') {
635
646
  if (hasApiKey(llmProvider, config.llm?.api_key)) {
636
- response += `✅ Oracle API key (${llmProvider})\n`;
647
+ response += `✅ Oracle API key \\(${escMd(llmProvider)}\\)\n`;
637
648
  }
638
649
  else {
639
- response += `❌ Oracle API key missing (${llmProvider})\n`;
650
+ response += `❌ Oracle API key missing \\(${escMd(llmProvider)}\\)\n`;
640
651
  }
641
652
  }
642
653
  // Sati
@@ -644,10 +655,10 @@ How can I assist you today?`;
644
655
  const satiProvider = sati?.provider || llmProvider;
645
656
  if (satiProvider && satiProvider !== 'ollama') {
646
657
  if (hasApiKey(satiProvider, sati?.api_key ?? config.llm?.api_key)) {
647
- response += `✅ Sati API key (${satiProvider})\n`;
658
+ response += `✅ Sati API key \\(${escMd(satiProvider)}\\)\n`;
648
659
  }
649
660
  else {
650
- response += `❌ Sati API key missing (${satiProvider})\n`;
661
+ response += `❌ Sati API key missing \\(${escMd(satiProvider)}\\)\n`;
651
662
  }
652
663
  }
653
664
  // Apoc
@@ -655,10 +666,10 @@ How can I assist you today?`;
655
666
  const apocProvider = apoc?.provider || llmProvider;
656
667
  if (apocProvider && apocProvider !== 'ollama') {
657
668
  if (hasApiKey(apocProvider, apoc?.api_key ?? config.llm?.api_key)) {
658
- response += `✅ Apoc API key (${apocProvider})\n`;
669
+ response += `✅ Apoc API key \\(${escMd(apocProvider)}\\)\n`;
659
670
  }
660
671
  else {
661
- response += `❌ Apoc API key missing (${apocProvider})\n`;
672
+ response += `❌ Apoc API key missing \\(${escMd(apocProvider)}\\)\n`;
662
673
  }
663
674
  }
664
675
  // Telegram token
@@ -745,7 +756,7 @@ How can I assist you today?`;
745
756
  let response = await this.oracle.chat(prompt);
746
757
  if (response) {
747
758
  try {
748
- await ctx.reply(toMd(response).text, { parse_mode: 'MarkdownV2' });
759
+ await ctx.reply((await toMd(response)).text, { parse_mode: 'MarkdownV2' });
749
760
  }
750
761
  catch {
751
762
  await ctx.reply(response);
@@ -754,12 +765,7 @@ How can I assist you today?`;
754
765
  // await ctx.reply(`Command not recognized. Type /help to see available commands.`);
755
766
  }
756
767
  async handleHelpCommand(ctx, user) {
757
- const helpMessage = `
758
- *Available Commands:*
759
-
760
- ${this.HELP_MESSAGE}
761
-
762
- How can I assist you today?`;
768
+ const helpMessage = `*Available Commands:*\n\n${this.HELP_MESSAGE}\n\nHow can I assist you today\\?`;
763
769
  await ctx.reply(helpMessage, { parse_mode: 'MarkdownV2' });
764
770
  }
765
771
  async handleZaionCommand(ctx, user) {
@@ -937,7 +943,7 @@ How can I assist you today?`;
937
943
  Construtor.probe(),
938
944
  ]);
939
945
  if (servers.length === 0) {
940
- await ctx.reply('*No MCP Servers Configured*\n\nThere are currently no MCP servers configured in the system.', { parse_mode: 'MarkdownV2' });
946
+ await ctx.reply('*No MCP Servers Configured*\n\nThere are currently no MCP servers configured in the system\\.', { parse_mode: 'MarkdownV2' });
941
947
  return;
942
948
  }
943
949
  const probeMap = new Map(probeResults.map(r => [r.name, r]));
@@ -984,7 +990,7 @@ How can I assist you today?`;
984
990
  }
985
991
  catch (error) {
986
992
  this.display.log('Error listing MCP servers: ' + (error instanceof Error ? error.message : String(error)), { source: 'Telegram', level: 'error' });
987
- await ctx.reply('An error occurred while retrieving the list of MCP servers. Please check the logs for more details.', { parse_mode: 'MarkdownV2' });
993
+ await ctx.reply('An error occurred while retrieving the list of MCP servers\\. Please check the logs for more details\\.', { parse_mode: 'MarkdownV2' });
988
994
  }
989
995
  }
990
996
  }
@@ -11,6 +11,7 @@ export const PATHS = {
11
11
  logs: LOGS_DIR,
12
12
  memory: path.join(MORPHEUS_ROOT, 'memory'),
13
13
  cache: path.join(MORPHEUS_ROOT, 'cache'),
14
+ browser: path.join(MORPHEUS_ROOT, 'cache', 'browser'),
14
15
  commands: path.join(MORPHEUS_ROOT, 'commands'),
15
16
  mcps: path.join(MORPHEUS_ROOT, 'mcps.json'),
16
17
  };
@@ -7,4 +7,5 @@ import './tools/network.js';
7
7
  import './tools/git.js';
8
8
  import './tools/packages.js';
9
9
  import './tools/system.js';
10
+ import './tools/browser.js';
10
11
  export { buildDevKit } from './registry.js';
@@ -0,0 +1,388 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { truncateOutput } from '../utils.js';
4
+ import { registerToolFactory } from '../registry.js';
5
+ import { PATHS } from '../../config/paths.js';
6
+ // ─── Module-level browser singleton ────────────────────────────────────────
7
+ let browserInstance = null;
8
+ let pageInstance = null;
9
+ let idleTimer = null;
10
+ let installPromise = null;
11
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
12
+ /**
13
+ * Ensures Chromium is downloaded to ~/.morpheus/cache/browser/.
14
+ * Downloads only once; subsequent calls return the cached executablePath.
15
+ */
16
+ async function ensureChromium() {
17
+ const { install, resolveBuildId, detectBrowserPlatform, computeExecutablePath, Browser: PBrowser, } = await import('@puppeteer/browsers');
18
+ const platform = detectBrowserPlatform();
19
+ const buildId = await resolveBuildId(PBrowser.CHROME, platform, 'stable');
20
+ // Check if already installed
21
+ const execPath = computeExecutablePath({
22
+ browser: PBrowser.CHROME,
23
+ buildId,
24
+ cacheDir: PATHS.browser,
25
+ });
26
+ const { default: fs } = await import('fs-extra');
27
+ if (await fs.pathExists(execPath)) {
28
+ return execPath;
29
+ }
30
+ // Download with progress indicator
31
+ process.stdout.write('[Morpheus] Installing Chromium for browser tools (first run, ~150MB)...\n');
32
+ const installed = await install({
33
+ browser: PBrowser.CHROME,
34
+ buildId,
35
+ cacheDir: PATHS.browser,
36
+ downloadProgressCallback: (downloaded, total) => {
37
+ const pct = total > 0 ? Math.round((downloaded / total) * 100) : 0;
38
+ process.stdout.write(`\r[Morpheus] Downloading Chromium: ${pct}% `);
39
+ },
40
+ });
41
+ process.stdout.write('\n[Morpheus] Chromium installed successfully.\n');
42
+ return installed.executablePath;
43
+ }
44
+ /**
45
+ * Returns (or creates) the browser singleton, resetting the idle timer.
46
+ * Handles Chromium lazy-install with a lock to prevent concurrent downloads.
47
+ */
48
+ async function acquireBrowser() {
49
+ const { launch } = await import('puppeteer-core');
50
+ const needsLaunch = !browserInstance || !browserInstance.connected;
51
+ if (needsLaunch) {
52
+ if (!installPromise) {
53
+ installPromise = ensureChromium().finally(() => {
54
+ installPromise = null;
55
+ });
56
+ }
57
+ const executablePath = await installPromise;
58
+ // Re-check after awaiting (another caller may have launched already)
59
+ if (!browserInstance || !browserInstance.connected) {
60
+ browserInstance = await launch({
61
+ executablePath,
62
+ headless: true,
63
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
64
+ });
65
+ pageInstance = await browserInstance.newPage();
66
+ }
67
+ }
68
+ else if (!pageInstance || pageInstance.isClosed()) {
69
+ pageInstance = await browserInstance.newPage();
70
+ }
71
+ // Reset idle timeout
72
+ if (idleTimer)
73
+ clearTimeout(idleTimer);
74
+ idleTimer = setTimeout(async () => {
75
+ try {
76
+ await pageInstance?.close();
77
+ }
78
+ catch { /* ignore */ }
79
+ try {
80
+ await browserInstance?.close();
81
+ }
82
+ catch { /* ignore */ }
83
+ pageInstance = null;
84
+ browserInstance = null;
85
+ idleTimer = null;
86
+ }, IDLE_TIMEOUT_MS);
87
+ return { browser: browserInstance, page: pageInstance };
88
+ }
89
+ // Best-effort cleanup on process exit
90
+ process.on('exit', () => {
91
+ try {
92
+ browserInstance?.process()?.kill();
93
+ }
94
+ catch { /* ignore */ }
95
+ });
96
+ // ─── Tool Definitions ───────────────────────────────────────────────────────
97
+ const browserNavigateTool = tool(async ({ url, wait_until, timeout_ms, return_html }) => {
98
+ try {
99
+ const { page } = await acquireBrowser();
100
+ await page.goto(url, {
101
+ waitUntil: (wait_until ?? 'domcontentloaded'),
102
+ timeout: timeout_ms ?? 30_000,
103
+ });
104
+ const title = await page.title();
105
+ const text = await page.evaluate(() => document.body.innerText);
106
+ const result = {
107
+ success: true,
108
+ url,
109
+ current_url: page.url(),
110
+ title,
111
+ text: truncateOutput(text),
112
+ };
113
+ if (return_html) {
114
+ result.html = truncateOutput(await page.content());
115
+ }
116
+ return JSON.stringify(result);
117
+ }
118
+ catch (err) {
119
+ return JSON.stringify({ success: false, url, error: err.message });
120
+ }
121
+ }, {
122
+ name: 'browser_navigate',
123
+ description: 'Navigate to a URL in a real browser (executes JavaScript). Use instead of http_request for SPAs, JS-heavy pages, or sites requiring interaction. Returns page title and text content.',
124
+ schema: z.object({
125
+ url: z.string().describe('Full URL to navigate to (must include https://)'),
126
+ wait_until: z
127
+ .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
128
+ .optional()
129
+ .describe('Wait condition. Default: domcontentloaded. Use networkidle0 for SPAs.'),
130
+ timeout_ms: z.number().optional().describe('Navigation timeout in ms. Default: 30000'),
131
+ return_html: z
132
+ .boolean()
133
+ .optional()
134
+ .describe('Also return raw HTML in response. Default: false'),
135
+ }),
136
+ });
137
+ const browserGetDomTool = tool(async ({ selector, include_attributes }) => {
138
+ try {
139
+ const { page } = await acquireBrowser();
140
+ const includeAttrs = include_attributes ?? true;
141
+ const dom = await page.evaluate(({ sel, attrs }) => {
142
+ const root = sel
143
+ ? document.querySelector(sel)
144
+ : document.body;
145
+ if (!root)
146
+ return null;
147
+ const RELEVANT_ATTRS = [
148
+ 'href', 'src', 'type', 'name', 'value',
149
+ 'placeholder', 'action', 'id', 'role', 'aria-label',
150
+ ];
151
+ function serialize(el, depth) {
152
+ const hasChildren = el.children.length > 0;
153
+ const node = {
154
+ tag: el.tagName.toLowerCase(),
155
+ };
156
+ if (el.id)
157
+ node.id = el.id;
158
+ if (el.className)
159
+ node.class = el.className;
160
+ if (!hasChildren) {
161
+ const txt = el.textContent?.trim();
162
+ if (txt)
163
+ node.text = txt.slice(0, 120);
164
+ }
165
+ if (attrs && el.attributes.length > 0) {
166
+ const attrMap = {};
167
+ for (const attr of el.attributes) {
168
+ if (RELEVANT_ATTRS.includes(attr.name)) {
169
+ attrMap[attr.name] = attr.value;
170
+ }
171
+ }
172
+ if (Object.keys(attrMap).length)
173
+ node.attrs = attrMap;
174
+ }
175
+ if (depth < 6 && hasChildren) {
176
+ node.children = Array.from(el.children)
177
+ .slice(0, 40)
178
+ .map((c) => serialize(c, depth + 1));
179
+ }
180
+ return node;
181
+ }
182
+ return serialize(root, 0);
183
+ }, { sel: selector ?? null, attrs: includeAttrs });
184
+ if (!dom) {
185
+ return JSON.stringify({ success: false, error: `Element not found: ${selector}` });
186
+ }
187
+ return JSON.stringify({ success: true, current_url: page.url(), dom: truncateOutput(JSON.stringify(dom, null, 2)) });
188
+ }
189
+ catch (err) {
190
+ return JSON.stringify({ success: false, error: err.message });
191
+ }
192
+ }, {
193
+ name: 'browser_get_dom',
194
+ description: 'Get a simplified DOM tree of the current page or a specific element. ' +
195
+ 'ALWAYS call this BEFORE browser_click or browser_fill to inspect page structure and identify the correct CSS selectors. ' +
196
+ 'Never guess selectors — analyze the DOM first.',
197
+ schema: z.object({
198
+ selector: z
199
+ .string()
200
+ .optional()
201
+ .describe('CSS selector to scope the DOM tree to. Omit to get the full body.'),
202
+ include_attributes: z
203
+ .boolean()
204
+ .optional()
205
+ .describe('Include relevant attributes (href, src, type, name, value, placeholder, role, aria-label). Default: true'),
206
+ }),
207
+ });
208
+ const browserClickTool = tool(async ({ selector, text, timeout_ms, wait_after_ms }) => {
209
+ try {
210
+ const { page } = await acquireBrowser();
211
+ if (!selector && !text) {
212
+ return JSON.stringify({ success: false, error: 'Provide either selector or text' });
213
+ }
214
+ const clickTimeout = timeout_ms ?? 10_000;
215
+ if (text) {
216
+ // Use Puppeteer pseudo-selector to find element by visible text
217
+ await page.locator(`::-p-text(${text})`).setTimeout(clickTimeout).click();
218
+ }
219
+ else {
220
+ await page.locator(selector).setTimeout(clickTimeout).click();
221
+ }
222
+ if (wait_after_ms) {
223
+ await new Promise((r) => setTimeout(r, wait_after_ms));
224
+ }
225
+ return JSON.stringify({
226
+ success: true,
227
+ current_url: page.url(),
228
+ title: await page.title(),
229
+ });
230
+ }
231
+ catch (err) {
232
+ return JSON.stringify({ success: false, error: err.message });
233
+ }
234
+ }, {
235
+ name: 'browser_click',
236
+ description: 'Click an element on the current browser page by CSS selector or visible text. ' +
237
+ 'The page must already be loaded via browser_navigate. ' +
238
+ 'Always inspect the DOM with browser_get_dom first to find the correct selector.',
239
+ schema: z.object({
240
+ selector: z
241
+ .string()
242
+ .optional()
243
+ .describe('CSS selector of the element to click (e.g. "button#submit", ".btn-login")'),
244
+ text: z
245
+ .string()
246
+ .optional()
247
+ .describe('Click element containing this visible text (alternative to selector)'),
248
+ timeout_ms: z
249
+ .number()
250
+ .optional()
251
+ .describe('Timeout to wait for the element in ms. Default: 10000'),
252
+ wait_after_ms: z
253
+ .number()
254
+ .optional()
255
+ .describe('Wait this many ms after clicking (for page transitions/animations). Default: 0'),
256
+ }),
257
+ });
258
+ const browserFillTool = tool(async ({ selector, value, press_enter, timeout_ms }) => {
259
+ try {
260
+ const { page } = await acquireBrowser();
261
+ await page.locator(selector).setTimeout(timeout_ms ?? 10_000).fill(value);
262
+ if (press_enter) {
263
+ await page.keyboard.press('Enter');
264
+ }
265
+ return JSON.stringify({ success: true, selector, filled: true });
266
+ }
267
+ catch (err) {
268
+ return JSON.stringify({ success: false, selector, error: err.message });
269
+ }
270
+ }, {
271
+ name: 'browser_fill',
272
+ description: 'Fill a form input or textarea field with a value. Clears any existing content first. ' +
273
+ 'Always inspect the DOM with browser_get_dom first to identify the correct CSS selector.',
274
+ schema: z.object({
275
+ selector: z.string().describe('CSS selector of the input/textarea element'),
276
+ value: z.string().describe('Value to type into the field'),
277
+ press_enter: z
278
+ .boolean()
279
+ .optional()
280
+ .describe('Press Enter after filling (triggers form submit in many cases). Default: false'),
281
+ timeout_ms: z
282
+ .number()
283
+ .optional()
284
+ .describe('Timeout to find the element in ms. Default: 10000'),
285
+ }),
286
+ });
287
+ /**
288
+ * Search via DuckDuckGo Lite (plain HTML, no JS, no bot detection).
289
+ * Uses a simple POST fetch — no browser required, much faster and more reliable
290
+ * than headless browser scraping of Google.
291
+ *
292
+ * DDG Lite returns results as: href="URL" class='result-link'>TITLE</a>
293
+ * and <td class='result-snippet'>SNIPPET</td>, paired by index.
294
+ * Sponsored links have URLs starting with "https://duckduckgo.com/y.js" and are filtered out.
295
+ */
296
+ const browserSearchTool = tool(async ({ query, num_results, language }) => {
297
+ try {
298
+ const max = num_results ?? 10;
299
+ // DDG region codes: "br-pt" for Brazil/Portuguese, "us-en" for US/English, etc.
300
+ // Map from simple lang code to DDG kl param
301
+ const regionMap = {
302
+ pt: 'br-pt', br: 'br-pt',
303
+ en: 'us-en', us: 'us-en',
304
+ es: 'es-es', fr: 'fr-fr',
305
+ de: 'de-de', it: 'it-it',
306
+ jp: 'jp-jp', ar: 'ar-es',
307
+ };
308
+ const lang = language ?? 'pt';
309
+ const kl = regionMap[lang] ?? lang;
310
+ const body = new URLSearchParams({ q: query, kl }).toString();
311
+ const res = await fetch('https://lite.duckduckgo.com/lite/', {
312
+ method: 'POST',
313
+ headers: {
314
+ 'Content-Type': 'application/x-www-form-urlencoded',
315
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
316
+ '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
317
+ },
318
+ body,
319
+ signal: AbortSignal.timeout(20_000),
320
+ });
321
+ if (!res.ok) {
322
+ return JSON.stringify({ success: false, query, error: `HTTP ${res.status}` });
323
+ }
324
+ const html = await res.text();
325
+ // Extract all result-link anchors (href uses double quotes, class uses single quotes)
326
+ const linkPattern = /href="(https?:\/\/[^"]+)"[^>]*class='result-link'>([^<]+)<\/a>/g;
327
+ const snippetPattern = /class='result-snippet'>([\s\S]*?)<\/td>/g;
328
+ const allLinks = [...html.matchAll(linkPattern)];
329
+ const allSnippets = [...html.matchAll(snippetPattern)];
330
+ // Pair links with snippets by index, filtering sponsored (DDG y.js redirect URLs)
331
+ const results = [];
332
+ for (let i = 0; i < allLinks.length && results.length < max; i++) {
333
+ const url = allLinks[i][1];
334
+ const title = allLinks[i][2].trim();
335
+ // Skip sponsored ads (redirected through duckduckgo.com/y.js)
336
+ if (url.startsWith('https://duckduckgo.com/'))
337
+ continue;
338
+ const snippet = allSnippets[i]
339
+ ? allSnippets[i][1].replace(/<[^>]+>/g, '').trim()
340
+ : '';
341
+ results.push({ title, url, snippet });
342
+ }
343
+ if (results.length === 0) {
344
+ return JSON.stringify({
345
+ success: false,
346
+ query,
347
+ error: 'No results found. The query may be too specific or DDG returned an unexpected response.',
348
+ });
349
+ }
350
+ return JSON.stringify({ success: true, query, results });
351
+ }
352
+ catch (err) {
353
+ return JSON.stringify({ success: false, query, error: err.message });
354
+ }
355
+ }, {
356
+ name: 'browser_search',
357
+ description: 'Search the internet using DuckDuckGo and return structured results (title, URL, snippet). ' +
358
+ 'Use this when you need to find current information, news, articles, documentation, or any web content. ' +
359
+ 'Returns up to 10 results by default. Does NOT require browser_navigate first — it is self-contained and fast.',
360
+ schema: z.object({
361
+ query: z.string().describe('Search query'),
362
+ num_results: z
363
+ .number()
364
+ .int()
365
+ .min(1)
366
+ .max(20)
367
+ .optional()
368
+ .describe('Number of results to return. Default: 10, max: 20'),
369
+ language: z
370
+ .string()
371
+ .optional()
372
+ .describe('Language/region code (e.g. "pt" for Portuguese/Brazil, "en" for English). Default: "pt"'),
373
+ }),
374
+ });
375
+ // ─── Factory ────────────────────────────────────────────────────────────────
376
+ export function createBrowserTools(_ctx) {
377
+ if (process.env.MORPHEUS_BROWSER_ENABLED === 'false') {
378
+ return [];
379
+ }
380
+ return [
381
+ browserNavigateTool,
382
+ browserGetDomTool,
383
+ browserClickTool,
384
+ browserFillTool,
385
+ browserSearchTool,
386
+ ];
387
+ }
388
+ registerToolFactory(createBrowserTools);
@@ -86,6 +86,8 @@ Available capabilities:
86
86
  - Perform network operations (curl, DNS, ping)
87
87
  - Manage packages (npm, yarn)
88
88
  - Inspect system information
89
+ - Navigate websites, inspect DOM, click elements, fill forms using a real browser (for JS-heavy pages and SPAs)
90
+ - Search the internet with browser_search (DuckDuckGo, returns structured results)
89
91
 
90
92
  OPERATING RULES:
91
93
  1. Use tools to accomplish the task. Do not speculate.
@@ -94,6 +96,21 @@ OPERATING RULES:
94
96
  4. If something fails, report the error and what you tried.
95
97
  5. Stay focused on the delegated task only.
96
98
 
99
+ BROWSER WORKFLOW RULES (when using browser tools):
100
+ 1. ALWAYS call browser_navigate first to load the page.
101
+ 2. ALWAYS call browser_get_dom before browser_click or browser_fill to inspect the page structure and choose the correct CSS selectors. Never guess selectors — analyze the DOM.
102
+ 3. Analyze the DOM to identify interactive elements (inputs, buttons, links), their selectors (id, class, name), and the page flow.
103
+ 4. If the task requires information you don't have (e.g. email, password, form fields, personal data), DO NOT proceed. Instead, immediately return to Oracle with a clear message listing exactly what information is needed from the user. Example: "To complete the login form I need: email address and password."
104
+ 5. After clicking or filling, call browser_get_dom again to verify the page changed as expected.
105
+ 6. Report what was done, the final URL, and any relevant content extracted.
106
+
107
+ SEARCH & FACT-CHECKING RULES (when using browser_search to answer factual questions):
108
+ 1. Call browser_search first to get a list of relevant sources.
109
+ 2. ALWAYS open at least 3 of the returned URLs with browser_navigate to read the actual content. Do not rely solely on the snippet — snippets may be outdated or incomplete.
110
+ 3. Cross-reference the information across the sources. If they agree, report the fact with confidence. If they disagree, report all versions found and indicate the discrepancy.
111
+ 4. Prefer authoritative sources (official team sites, major sports outlets, official event pages) over aggregators.
112
+ 5. Include the source URLs in your final report so Oracle can pass them to the user.
113
+
97
114
  ${context ? `CONTEXT FROM ORACLE:\n${context}` : ""}
98
115
  `);
99
116
  const userMessage = new HumanMessage(task);
@@ -33,6 +33,8 @@ Use this tool when the user asks for ANY of the following:
33
33
  - Process management: list processes, kill processes, check ports
34
34
  - Network: ping hosts, curl URLs, DNS lookups
35
35
  - System info: environment variables, OS info, disk space, memory
36
+ - Internet search: search DuckDuckGo and verify facts by reading at least 3 sources via browser_navigate before reporting results.
37
+ - Browser automation: navigate websites (JS/SPA), inspect DOM, click elements, fill forms. Apoc will ask for missing user input (e.g. credentials, form fields) before proceeding.
36
38
 
37
39
  Provide a clear natural language task description. Optionally provide context
38
40
  from the current conversation to help Apoc understand the broader goal.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morpheus-cli",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Morpheus is a local AI agent for developers, running as a CLI daemon that connects to LLMs, local tools, and MCPs, enabling interaction via Terminal, Telegram, and Discord. Inspired by the character Morpheus from *The Matrix*, the project acts as an intelligent orchestrator, bridging the gap between the developer and complex systems.",
5
5
  "bin": {
6
6
  "morpheus": "./bin/morpheus.js"
@@ -50,6 +50,7 @@
50
50
  "mcp-remote": "^0.1.38",
51
51
  "open": "^11.0.0",
52
52
  "ora": "^9.1.0",
53
+ "puppeteer-core": "^23.11.1",
53
54
  "sqlite-vec": "^0.1.7-alpha.2",
54
55
  "telegraf": "^4.16.3",
55
56
  "telegram-markdown-v2": "^0.0.4",