morpheus-cli 0.4.13 → 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);
@@ -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 });
@@ -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);
@@ -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.13",
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",