nothumanallowed 9.5.2 → 9.6.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.
@@ -7,9 +7,6 @@
7
7
  * Zero external dependencies — pure Node.js 22.
8
8
  */
9
9
 
10
- import os from 'os';
11
- import path from 'path';
12
-
13
10
  import {
14
11
  listMessages,
15
12
  getMessage,
@@ -65,6 +62,8 @@ export const DESTRUCTIVE_ACTIONS = new Set([
65
62
  'notify_remind',
66
63
  'slack_send',
67
64
  'github_create_issue',
65
+ 'cron_add',
66
+ 'cron_remove',
68
67
  ]);
69
68
 
70
69
  // ── Tool Definitions (for system prompt) ─────────────────────────────────────
@@ -75,16 +74,14 @@ export const DESTRUCTIVE_ACTIONS = new Set([
75
74
  */
76
75
  export const TOOL_DEFINITIONS = `
77
76
  You have access to the following tools. When the user's message requires an action,
78
- output one or more fenced JSON blocks:
77
+ output EXACTLY ONE fenced JSON block per action:
79
78
 
80
79
  \`\`\`json
81
80
  {"action": "<tool_name>", "params": { ... }}
82
81
  \`\`\`
83
82
 
84
- You can include multiple JSON blocks in one response for sequential actions.
85
- You may include conversational text BEFORE, BETWEEN, or AFTER JSON blocks.
86
- If no action is needed, respond normally without any JSON block.
87
- CRITICAL: Never output a JSON block as a "suggestion" or "let me try" — every JSON block WILL be executed immediately. Only output a JSON block when you are certain the action should be performed.
83
+ You may include conversational text BEFORE or AFTER the JSON block. If no action
84
+ is needed, respond normally without any JSON block.
88
85
 
89
86
  TOOLS:
90
87
 
@@ -268,75 +265,42 @@ TOOLS:
268
265
  46. birthday_add(name: string, date: string)
269
266
  Add or update a birthday for a contact. Name is the contact name (must exist in Google Contacts — creates one if not found). Date is MM-DD (e.g. "04-06" for April 6) or YYYY-MM-DD.
270
267
 
271
- --- WEB SEARCH & FETCH ---
272
-
273
- 47. web_search(query: string, deep?: boolean, screenshot?: boolean)
274
- Search the web using DuckDuckGo. Returns titles, URLs, and snippets.
275
- Set deep=true to also fetch and extract the top 3 pages' full content (slower but more detailed).
276
- Set screenshot=true when the user asks for a screenshot/image of the results — this renders results as a visual page and captures a screenshot. ALWAYS set screenshot=true if the user mentions "screenshot", "screen", "immagine", "foto", "mostra", or "vedi" in relation to search results.
277
- ALWAYS use this for ANY web search request ("search for X", "find X", "look up X", "cerca X").
278
- Do NOT open Google/Bing in the browser for searches — use this tool instead. It's faster and never gets blocked.
279
-
280
- 48. fetch_url(url: string)
281
- Fetch a web page and extract its text content. SSRF-protected (blocks private IPs, localhost).
282
- Returns: title, excerpt, and body text (max 8000 chars). Only fetches text/html/json/xml.
283
- Use this when the user provides a specific URL to read, summarize, or analyze.
284
-
285
- --- BROWSER AUTOMATION ---
286
-
287
- 49. browser_open(url: string, waitForLoad?: boolean)
288
- Open a URL in a headless Chrome browser. Launches Chrome automatically on first use.
289
- SSRF-protected (blocks private IPs, localhost). Renders JavaScript, SPAs, and dynamic pages.
290
- Use this when you need to interact with a page (click, type, screenshot) or when fetch_url fails on JS-rendered content.
291
- WARNING: Do NOT use this to search the web — use web_search instead. Google/Bing block automated browsers with CAPTCHAs.
292
-
293
- 50. browser_screenshot(saveTo?: string)
294
- Capture a screenshot of the current browser viewport (what's visible on screen).
295
- saveTo saves to a file path (e.g. "~/screenshot.png"). Returns base64-encoded image.
296
- ALWAYS use viewport screenshots (the default). Do NOT pass fullPage=true — it produces oversized images.
297
- Screenshots are automatically compressed as JPEG for efficiency.
298
-
299
- 51. browser_click(text?: string, selector?: string, x?: number, y?: number)
300
- Click an element on the page by visible text, CSS selector, or x/y coordinates.
301
- PREFERRED: use text="Rifiuta tutto" or text="Submit" to click buttons/links by their visible label (case-insensitive partial match).
302
- CSS selector: selector="#submit-btn", selector="a.nav-link"
303
- Coordinates: x=500, y=300 for precise clicking.
304
- Always try text first — it works for buttons, links, and any clickable element regardless of CSS structure.
305
-
306
- 52. browser_type(text: string, selector?: string, clear?: boolean, delay?: number)
307
- Type text into an input field. If selector is provided, clicks the element first to focus it.
308
- clear=true clears existing content before typing. delay=50 types with 50ms delay between keys.
309
-
310
- 53. browser_extract(selector?: string, mode?: "text"|"html"|"value"|"attribute", attribute?: string, all?: boolean)
311
- Extract content from the page. Default: extract all text from body.
312
- selector="h1" extracts the first h1 text. all=true extracts from ALL matching elements.
313
- mode="value" gets input values. mode="attribute" with attribute="href" gets link URLs.
314
- mode="html" gets raw HTML of the element.
315
-
316
- 54. browser_js(code: string)
317
- Execute arbitrary JavaScript in the browser page context and return the result.
318
- The code runs inside the page — you have access to document, window, fetch, etc.
319
- Example: "document.querySelectorAll('.item').length" returns the count of items.
320
- Use for complex interactions, form filling, or data extraction that other tools can't handle.
321
-
322
- 55. browser_wait(selector: string, timeout?: number, visible?: boolean)
323
- Wait for an element to appear on the page. Default timeout: 10 seconds.
324
- visible=true (default) waits for the element to be visible, not just in DOM.
325
- Use after clicking or navigating to wait for dynamic content to load.
326
-
327
- 56. browser_scroll(direction?: "up"|"down"|"top"|"bottom", amount?: number)
328
- Scroll the page. direction="down" (default) scrolls down 500px. "top"/"bottom" go to extremes.
329
- amount=1000 scrolls 1000px instead of default 500.
330
-
331
- 57. browser_key(key: string, ctrl?: boolean, shift?: boolean, alt?: boolean)
332
- Press a keyboard key. Keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space.
333
- ctrl=true holds Ctrl (or Cmd on Mac). Example: key="Enter" to submit a form.
334
-
335
- 58. browser_close()
336
- Close the browser. Frees resources. Browser auto-closes when NHA exits.
268
+ --- SCHEDULED TASKS ---
269
+
270
+ 47. cron_add(schedule: string, prompt: string, agent?: string)
271
+ Create a recurring background task. The daemon executes it automatically.
272
+ Schedule formats: "every 5m", "every 2h", "every monday 9am", "daily 8:30", "at 14:00", "hourly".
273
+ Example: cron_add("every monday 9am", "check open PRs on my GitHub repos", "forge")
274
+
275
+ 48. cron_list()
276
+ List all scheduled background tasks with their status, run count, and last result.
277
+
278
+ 49. cron_remove(index: number)
279
+ Remove a scheduled task by its number (1-based, from cron_list).
280
+
281
+ --- SCREEN & VISION ---
282
+
283
+ 50. screen_capture(monitor?: number)
284
+ Capture a screenshot of the user's desktop screen. Returns the image for visual analysis.
285
+ Use this when the user asks you to look at their screen, analyze what's visible, help with something on screen, etc.
286
+ Optional: monitor number (default 1, for multi-monitor setups).
287
+
288
+ 51. screen_analyze(question: string)
289
+ Capture the screen AND analyze it with a specific question. Combines capture + vision in one call.
290
+ Example: screen_analyze("what error is shown in the terminal?")
291
+
292
+ --- CANVAS ---
293
+
294
+ 52. canvas_render(html: string, title?: string)
295
+ Render HTML content in the web dashboard's canvas panel. Use this to show charts, tables, diagrams,
296
+ dashboards, formatted reports, or any visual content to the user. The HTML is rendered in an isolated iframe.
297
+ You can use inline CSS and JavaScript. External libraries (Chart.js, Mermaid, etc.) can be loaded via CDN.
298
+ Example: canvas_render("<h1>Sales Report</h1><canvas id='chart'></canvas><script src='https://cdn.jsdelivr.net/npm/chart.js'></script>")
299
+
300
+ 53. canvas_clear()
301
+ Clear the canvas panel.
337
302
 
338
303
  RULES:
339
- - CRITICAL: For web searches ("search for X", "find X online", "look up X"), ALWAYS use web_search — NEVER open Google/Bing/DuckDuckGo in the browser. web_search is faster, more reliable, and doesn't get blocked by CAPTCHAs. Only use browser_open for interacting with specific websites (filling forms, clicking buttons, taking screenshots of specific pages).
340
304
  - For search/read operations, execute immediately and present results conversationally.
341
305
  - For write/send/delete operations (gmail_send, gmail_reply, gmail_delete, calendar_create, calendar_move, calendar_update, contact_delete, task_done, notify_remind), DESCRIBE what you're about to do and include the JSON block so the system can ask the user for confirmation.
342
306
  - For schedule_meeting and schedule_draft_email, execute immediately — these are read operations that suggest slots.
@@ -348,9 +312,6 @@ RULES:
348
312
  - Dates: today is {{TODAY}}. Infer relative dates from this.
349
313
  - The user's timezone is {{TIMEZONE}}.
350
314
  - CRITICAL: when creating calendar events, always use LOCAL time in format "YYYY-MM-DDTHH:MM:SS" WITHOUT any Z suffix or timezone offset.
351
- - LANGUAGE: Respond in {{LANGUAGE}}. All conversational text, explanations, and descriptions must be in {{LANGUAGE}}. Tool names and JSON blocks remain in English.
352
- - BROWSER TIP: When extracting data from a page, prefer browser_js with code "document.body.innerText.slice(0, 3000)" to get all visible text. This is more reliable than guessing CSS selectors.
353
- - API TIP: For npm package info, use fetch_url with the registry API: fetch_url("https://registry.npmjs.org/PACKAGE/latest") for version/description, and fetch_url("https://api.npmjs.org/downloads/point/last-week/PACKAGE") for weekly downloads. These are JSON APIs, much more reliable than scraping the npm website.
354
315
  `.trim();
355
316
 
356
317
  // ── Action Parser ────────────────────────────────────────────────────────────
@@ -502,23 +463,9 @@ export function buildSystemPrompt(persona, personaDescription, config, initialCo
502
463
  const today = new Date().toISOString().split('T')[0];
503
464
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
504
465
 
505
- // Detect system language from locale
506
- const locale = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
507
- const langCode = locale.split('-')[0];
508
- const LANG_MAP = {
509
- en: 'English', it: 'Italian', es: 'Spanish', fr: 'French', de: 'German',
510
- pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', ja: 'Japanese',
511
- ko: 'Korean', zh: 'Chinese', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish',
512
- sv: 'Swedish', da: 'Danish', no: 'Norwegian', fi: 'Finnish', cs: 'Czech',
513
- ro: 'Romanian', hu: 'Hungarian', el: 'Greek', th: 'Thai', vi: 'Vietnamese',
514
- uk: 'Ukrainian', he: 'Hebrew', id: 'Indonesian', ms: 'Malay',
515
- };
516
- const language = config?.language || LANG_MAP[langCode] || 'English';
517
-
518
466
  let prompt = TOOL_DEFINITIONS
519
467
  .replace('{{TODAY}}', today)
520
- .replace('{{TIMEZONE}}', tz)
521
- .replace(/\{\{LANGUAGE\}\}/g, language);
468
+ .replace('{{TIMEZONE}}', tz);
522
469
 
523
470
  prompt += `\n\n${personaDescription}`;
524
471
 
@@ -1112,264 +1059,70 @@ export async function executeTool(action, params, config) {
1112
1059
  return `Birthday set for ${contact.name}: ${monthName} ${day}. It will appear in the Birthdays tab.`;
1113
1060
  }
1114
1061
 
1115
- // ── Browser Automation ────────────────────────────────────────────
1116
- case 'browser_open': {
1117
- const url = params.url;
1118
- if (!url) return 'A URL is required.';
1119
-
1120
- // Intercept search engine URLs — redirect to web_search tool
1121
- const searchEngines = /^https?:\/\/(www\.)?(google|bing|duckduckgo|yahoo|baidu|yandex)\.(com|it|co\.uk|de|fr|es|org|net)/i;
1122
- if (searchEngines.test(url)) {
1123
- // Extract search query if present in URL
1124
- try {
1125
- const u = new URL(url);
1126
- const q = u.searchParams.get('q') || u.searchParams.get('query') || u.searchParams.get('p');
1127
- if (q) {
1128
- return `REDIRECT: Use web_search instead. Search engines block automated browsers. Executing web_search for "${q}"...\n\n` +
1129
- await executeTool('web_search', { query: q }, config);
1130
- }
1131
- } catch {}
1132
- return 'Do NOT open search engines in the browser — they block automated access with CAPTCHAs. Use the web_search tool instead: {"action": "web_search", "params": {"query": "your search terms"}}';
1133
- }
1134
-
1135
- const be = await import('./browser-engine.mjs');
1136
- const result = await be.browserOpen(url, {
1137
- waitForLoad: params.waitForLoad !== false,
1138
- });
1139
- if (result.error) return `Browser error: ${result.message}`;
1140
-
1141
- return `Page loaded: "${result.title}"\nURL: ${result.url}`;
1142
- }
1143
-
1144
- case 'browser_screenshot': {
1145
- const be = await import('./browser-engine.mjs');
1146
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1147
-
1148
- // Scroll to top before screenshot so user sees the most important content
1149
- await be.browserScroll({ direction: 'top' });
1150
- await new Promise(r => setTimeout(r, 300));
1151
-
1152
- const saveTo = params.saveTo
1153
- ? params.saveTo.replace(/^~/, os.homedir())
1154
- : null;
1155
-
1156
- // Always use JPEG for efficiency (smaller base64, faster rendering in web UI)
1157
- const result = await be.browserScreenshot({
1158
- fullPage: false, // Always viewport — fullPage produces oversized images
1159
- format: 'jpeg',
1160
- quality: 75,
1161
- saveTo,
1162
- });
1163
- if (result.error) return `Screenshot error: ${result.message}`;
1164
-
1165
- if (result.savedTo) {
1166
- return `Screenshot saved to: ${result.savedTo} (${Math.round(result.size / 1024)}KB base64)`;
1167
- }
1168
-
1169
- // Return base64 for LLM vision analysis
1170
- return `Screenshot captured (${Math.round(result.size / 1024)}KB base64 PNG).\n[Base64 data available — use browser_js or browser_extract to analyze page content instead]`;
1171
- }
1172
-
1173
- case 'browser_click': {
1174
- const be = await import('./browser-engine.mjs');
1175
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1176
-
1177
- const result = await be.browserClick({
1178
- text: params.text,
1179
- selector: params.selector,
1180
- x: params.x,
1181
- y: params.y,
1182
- });
1183
- if (result.error) return `Click error: ${result.message}`;
1184
-
1185
- return `Clicked: ${result.selector} at (${result.x}, ${result.y})`;
1186
- }
1187
-
1188
- case 'browser_type': {
1189
- const be = await import('./browser-engine.mjs');
1190
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1191
-
1192
- if (!params.text) return 'Text is required.';
1193
-
1194
- const result = await be.browserType({
1195
- text: params.text,
1196
- selector: params.selector,
1197
- clear: params.clear || false,
1198
- delay: params.delay || 0,
1199
- });
1200
- if (result.error) return `Type error: ${result.message}`;
1201
-
1202
- return `Typed ${result.length} chars into ${result.selector}`;
1203
- }
1204
-
1205
- case 'browser_extract': {
1206
- const be = await import('./browser-engine.mjs');
1207
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1062
+ // ── Screen Capture + Vision ────────────────────────────────────────
1063
+ case 'screen_capture': {
1064
+ const { captureScreen } = await import('./screen-capture.mjs');
1065
+ const result = captureScreen({ monitor: params.monitor || 1 });
1066
+ if (!result.ok) return `Screen capture failed: ${result.error}`;
1208
1067
 
1209
- const result = await be.browserExtract({
1210
- selector: params.selector || 'body',
1211
- mode: params.mode || 'text',
1212
- attribute: params.attribute,
1213
- all: params.all || false,
1214
- });
1215
- if (result.error) return `Extract error: ${result.message}`;
1068
+ // Save screenshot to file for the UI to display
1069
+ const screenshotPath = result.path;
1216
1070
 
1217
- return `[${result.selector}] (${result.length} chars):\n${result.content}`;
1071
+ // Return a compact description + mark that we have an image
1072
+ // The calling layer (chat/ui) will handle displaying the image
1073
+ return `[SCREENSHOT]${screenshotPath}[/SCREENSHOT]\nScreenshot captured successfully. I can see your screen. What would you like me to analyze?`;
1218
1074
  }
1219
1075
 
1220
- case 'browser_js': {
1221
- const be = await import('./browser-engine.mjs');
1222
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1223
-
1224
- if (!params.code) return 'JavaScript code is required.';
1076
+ case 'screen_analyze': {
1077
+ const { captureScreen } = await import('./screen-capture.mjs');
1078
+ const result = captureScreen({ monitor: params.monitor || 1 });
1079
+ if (!result.ok) return `Screen capture failed: ${result.error}`;
1225
1080
 
1226
- const result = await be.browserEval(params.code);
1227
- if (result.error) return `JS error: ${result.message}`;
1228
-
1229
- return `[${result.type}] ${result.result}`;
1081
+ // For vision analysis, we need to send the image to the LLM
1082
+ // Return the base64 with a marker so the chat layer sends it as a vision message
1083
+ const question = params.question || 'Describe what you see on screen.';
1084
+ return `[VISION_REQUEST]${result.base64}[/VISION_REQUEST]\n[VISION_QUESTION]${question}[/VISION_QUESTION]`;
1230
1085
  }
1231
1086
 
1232
- case 'browser_wait': {
1233
- const be = await import('./browser-engine.mjs');
1234
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1235
-
1236
- if (!params.selector) return 'A CSS selector is required.';
1237
-
1238
- const result = await be.browserWaitFor(params.selector, {
1239
- timeout: params.timeout || 10000,
1240
- visible: params.visible !== false,
1241
- });
1242
- if (result.error) return `Wait failed: ${result.message}`;
1243
-
1244
- return `Element found: ${result.selector} (${result.elapsed}ms)`;
1087
+ // ── Canvas ───────────────────────────────────────────────────────────
1088
+ case 'canvas_render': {
1089
+ const html = params.html;
1090
+ const title = params.title || 'Canvas';
1091
+ if (!html) return 'Error: html parameter is required.';
1092
+ // Return a marker that the UI layer will intercept to render in the canvas panel
1093
+ return `[CANVAS_RENDER]${JSON.stringify({ html, title })}[/CANVAS_RENDER]\nRendered "${title}" in the canvas panel.`;
1245
1094
  }
1246
1095
 
1247
- case 'browser_scroll': {
1248
- const be = await import('./browser-engine.mjs');
1249
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1250
-
1251
- const result = await be.browserScroll({
1252
- direction: params.direction || 'down',
1253
- amount: params.amount || 500,
1254
- });
1255
- if (result.error) return `Scroll error: ${result.message}`;
1256
-
1257
- return `Scrolled ${result.direction}`;
1096
+ case 'canvas_clear': {
1097
+ return '[CANVAS_CLEAR]Canvas cleared.[/CANVAS_CLEAR]';
1258
1098
  }
1259
1099
 
1260
- case 'browser_key': {
1261
- const be = await import('./browser-engine.mjs');
1262
- if (!be.isBrowserRunning()) return 'No browser open. Use browser_open first.';
1263
-
1264
- if (!params.key) return 'A key name is required (e.g. Enter, Tab, Escape).';
1265
-
1266
- const result = await be.browserKeyPress(params.key, {
1267
- ctrl: params.ctrl,
1268
- shift: params.shift,
1269
- alt: params.alt,
1270
- });
1271
- if (result.error) return `Key error: ${result.message}`;
1272
-
1273
- return `Pressed: ${result.key}`;
1100
+ // ── Cron / Heartbeat ───────────────────────────────────────────────
1101
+ case 'cron_add': {
1102
+ const { addCronJob } = await import('./ops-daemon.mjs');
1103
+ const result = addCronJob(params.schedule, params.prompt, { agent: params.agent || null });
1104
+ if (result.ok) {
1105
+ return `Scheduled task created: "${params.schedule}" → "${params.prompt}". The daemon will execute it automatically. Start daemon with \`nha ops start\` if not running.`;
1106
+ }
1107
+ return `Failed to create scheduled task: ${result.error}`;
1274
1108
  }
1275
1109
 
1276
- case 'browser_close': {
1277
- const be = await import('./browser-engine.mjs');
1278
- const result = await be.browserClose();
1279
- return result.message;
1110
+ case 'cron_list': {
1111
+ const { listCronJobs } = await import('./ops-daemon.mjs');
1112
+ const jobs = listCronJobs();
1113
+ if (jobs.length === 0) return 'No scheduled tasks configured.';
1114
+ return jobs.map((j, i) =>
1115
+ `${i + 1}. [${j.enabled ? 'active' : 'paused'}] ${j.schedule} → ${j.prompt} (runs: ${j.runCount}, last: ${j.lastRun ? new Date(j.lastRun).toLocaleString() : 'never'})`
1116
+ ).join('\n');
1280
1117
  }
1281
1118
 
1282
- // ── Web Search & Fetch ──────────────────────────────────────────────
1283
- case 'web_search': {
1284
- const wt = await import('./web-tools.mjs');
1285
- const query = params.query;
1286
- if (!query) return 'A search query is required.';
1287
-
1288
- if (params.deep) {
1289
- const result = await wt.webSearchDeep(query, 3);
1290
- if (result.error) return `Search error: ${result.message}`;
1291
- if (result.results.length === 0) return `No results found for "${query}".`;
1292
-
1293
- const lines = [`Web search: "${query}" — ${result.resultCount} results, ${result.deepFetched} pages fetched\n`];
1294
-
1295
- // Deep results first (with content)
1296
- for (const dr of result.deepResults) {
1297
- lines.push(`--- ${dr.title} ---`);
1298
- lines.push(`URL: ${dr.url}`);
1299
- lines.push(dr.content.slice(0, 2000));
1300
- lines.push('');
1301
- }
1302
-
1303
- // Remaining results (snippets only)
1304
- const deepUrls = new Set(result.deepResults.map(d => d.url));
1305
- for (const r of result.results.filter(r => !deepUrls.has(r.url))) {
1306
- lines.push(`${r.title}`);
1307
- lines.push(` ${r.url}`);
1308
- if (r.snippet) lines.push(` ${r.snippet}`);
1309
- }
1310
-
1311
- return lines.join('\n');
1312
- }
1313
-
1314
- const result = await wt.webSearch(query);
1315
- if (result.error) return `Search error: ${result.message}`;
1316
- if (result.results.length === 0) return `No results found for "${query}".`;
1317
-
1318
- const textResult = `Web search: "${query}" — ${result.resultCount} results\n\n` +
1319
- result.results.map((r, i) =>
1320
- `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`
1321
- ).join('\n\n');
1322
-
1323
- // If screenshot requested, render results as HTML in browser and capture
1324
- if (params.screenshot) {
1325
- try {
1326
- const be = await import('./browser-engine.mjs');
1327
- // Ensure browser is running
1328
- if (!be.isBrowserRunning()) {
1329
- await be.browserOpen('https://example.com', { waitForLoad: true });
1330
- }
1331
- // Build search results HTML
1332
- const esc = (s) => (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1333
- const htmlItems = result.results.map((r, i) =>
1334
- `<div style="margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid #3c4043"><div style="font-size:18px;color:#8ab4f8;margin-bottom:4px;font-weight:600">${i + 1}. ${esc(r.title)}</div><div style="font-size:13px;color:#bdc1c6;margin-bottom:6px">${esc(r.url)}</div><div style="font-size:14px;color:#969ba1;line-height:1.5">${esc(r.snippet)}</div></div>`
1335
- ).join('');
1336
- const fullHtml = `<html><head><style>body{background:#202124;color:#e8eaed;font-family:Arial,Helvetica,sans-serif;padding:24px 40px;max-width:800px;margin:0 auto}h1{font-size:22px;color:#8ab4f8;margin-bottom:24px;border-bottom:2px solid #3c4043;padding-bottom:12px}</style></head><body><h1>Search results: "${esc(query)}"</h1>${htmlItems}<div style="color:#5f6368;font-size:12px;margin-top:16px">Powered by NHA web_search — ${result.resultCount} results via DuckDuckGo</div></body></html>`;
1337
- // Render by injecting HTML into current page via JS
1338
- await be.browserEval(`document.open();document.write(${JSON.stringify(fullHtml)});document.close();`);
1339
- await new Promise(r => setTimeout(r, 300));
1340
- const ss = await be.browserScreenshot({ fullPage: false, format: 'jpeg', quality: 75 });
1341
- if (!ss.error) {
1342
- // Save to disk for persistence
1343
- const ssDir = path.join(os.homedir(), '.nha', 'screenshots');
1344
- const fsMod = await import('fs');
1345
- fsMod.mkdirSync(ssDir, { recursive: true });
1346
- const ssFilename = `ss-${Date.now()}.jpg`;
1347
- fsMod.writeFileSync(path.join(ssDir, ssFilename), Buffer.from(ss.base64, 'base64'));
1348
- return textResult + `\n\n[Screenshot of results captured (${Math.round(ss.size / 1024)}KB) file:${ssFilename}]`;
1349
- }
1350
- } catch { /* screenshot failed */ }
1119
+ case 'cron_remove': {
1120
+ const { removeCronJob } = await import('./ops-daemon.mjs');
1121
+ const result = removeCronJob(params.index || params.id);
1122
+ if (result.ok) {
1123
+ return `Removed scheduled task: "${result.removed.schedule}" "${result.removed.prompt}"`;
1351
1124
  }
1352
-
1353
- return textResult;
1354
- }
1355
-
1356
- case 'fetch_url': {
1357
- const wt = await import('./web-tools.mjs');
1358
- const url = params.url;
1359
- if (!url) return 'A URL is required.';
1360
-
1361
- const result = await wt.fetchUrl(url);
1362
- if (result.error) return `Fetch error: ${result.message}`;
1363
-
1364
- const lines = [];
1365
- if (result.title) lines.push(`Title: ${result.title}`);
1366
- lines.push(`URL: ${result.url || url}`);
1367
- lines.push(`Status: ${result.status}`);
1368
- if (result.truncated) lines.push('[Content was truncated due to size limits]');
1369
- lines.push('');
1370
- lines.push(result.body);
1371
-
1372
- return lines.join('\n');
1125
+ return `Failed to remove: ${result.error}`;
1373
1126
  }
1374
1127
 
1375
1128
  default: