neoagent 2.3.1-beta.2 → 2.3.1-beta.3

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.
@@ -138,7 +138,7 @@ When drafting on behalf of the user, match their likely voice from available con
138
138
  If the user approves a previously shown draft, send that draft rather than silently rewriting it.
139
139
 
140
140
  TASKS
141
- Use one-time schedule triggers for single reminders or delayed actions, recurring schedule triggers for repeating automation, and official integration triggers when the task should react to connected Gmail, Outlook, Slack, Teams, or WhatsApp Personal events. When calling task tools, prefer one unified trigger section: trigger={ type, config }. Make task prompts self-contained: who/what to check, exact action to take, when to notify, and which channel to use if known.
141
+ Use manual triggers for run-on-demand tasks, one-time schedule triggers for single reminders or delayed actions, recurring schedule triggers for repeating automation, and official integration triggers when the task should react to connected Gmail, Outlook, Slack, Teams, or WhatsApp Personal events. When calling task tools, prefer one unified trigger section: trigger={ type, config }. Make task prompts self-contained: who/what to check, exact action to take, when to notify, and which channel to use if known.
142
142
  Do not create vague tasks like "check this" when the future run would not know what "this" means. Resolve references into names, links, file paths, IDs, dates, and success criteria before saving the task.
143
143
  For notification tasks, distinguish between notifying the user in their current messaging channel, emailing the user, and contacting someone else. Default reminders should notify the user through the active messaging channel unless the user explicitly asks for email, phone, or a third party.
144
144
  When creating or updating a task, include whether it should notify every time, only on change, only on errors, or only when a condition is met. If unspecified, choose the least noisy useful behavior and say what you chose.
@@ -973,8 +973,8 @@ function getAvailableTools(app, options = {}) {
973
973
  type: 'object',
974
974
  properties: {
975
975
  name: { type: 'string', description: 'Short descriptive name for the task.' },
976
- trigger: { type: 'object', description: 'Unified trigger object. Prefer { type: "schedule" | integration_trigger_type, config: {...} }.' },
977
- trigger_type: { type: 'string', description: 'Trigger type such as schedule, gmail_message_received, outlook_email_received, slack_message_received, teams_message_received, weather_event, or whatsapp_personal_message_received.' },
976
+ trigger: { type: 'object', description: 'Unified trigger object. Prefer { type: "manual" | "schedule" | integration_trigger_type, config: {...} }.' },
977
+ trigger_type: { type: 'string', description: 'Trigger type such as manual, schedule, gmail_message_received, outlook_email_received, slack_message_received, teams_message_received, weather_event, or whatsapp_personal_message_received.' },
978
978
  trigger_config: { type: 'object', description: 'Trigger-specific configuration object. For schedule triggers prefer { mode: "recurring", cronExpression: "m h dom mon dow" } or { mode: "one_time", runAt: ISO datetime }. 5-field cron only (seconds unsupported).' },
979
979
  prompt: { type: 'string', description: 'The instructions the agent will run when the trigger fires.' },
980
980
  enabled: { type: 'boolean', description: 'Whether to activate immediately.' },
@@ -1010,7 +1010,7 @@ function getAvailableTools(app, options = {}) {
1010
1010
  task_id: { type: 'number', description: 'The numeric ID of the task to update.' },
1011
1011
  name: { type: 'string', description: 'New name for the task.' },
1012
1012
  trigger: { type: 'object', description: 'Unified trigger object. Use { type, config } to update trigger in one section.' },
1013
- trigger_type: { type: 'string', description: 'Updated trigger type.' },
1013
+ trigger_type: { type: 'string', description: 'Updated trigger type, e.g. manual, schedule, or integration trigger type.' },
1014
1014
  trigger_config: { type: 'object', description: 'Updated trigger-specific configuration. For schedule triggers use mode+cronExpression (recurring) or mode+runAt (one_time).' },
1015
1015
  prompt: { type: 'string', description: 'Updated task prompt.' },
1016
1016
  enabled: { type: 'boolean', description: 'Enable or disable the task.' },
@@ -1152,6 +1152,17 @@ function getAvailableTools(app, options = {}) {
1152
1152
  required: ['image_path']
1153
1153
  }
1154
1154
  },
1155
+ {
1156
+ name: 'ocr_extract',
1157
+ description: 'Extract raw text from an image locally using Tesseract OCR. This is faster and completely offline compared to analyze_image.',
1158
+ parameters: {
1159
+ type: 'object',
1160
+ properties: {
1161
+ image_path: { type: 'string', description: 'Absolute path to the image file' }
1162
+ },
1163
+ required: ['image_path']
1164
+ }
1165
+ },
1155
1166
  {
1156
1167
  name: 'read_health_data',
1157
1168
  description: 'Read the user\'s synced mobile health data. Omit metric_type for a summary of all available metrics. With metric_type, returns an aggregate summary (total, avg, min, max over all stored data) plus the most recent individual records. Always report the summary figures — avoid listing every raw record.',
@@ -2311,13 +2322,17 @@ async function executeTool(toolName, args, context, engine) {
2311
2322
  if (!resolvedTrigger.hasType || !resolvedTrigger.triggerType) {
2312
2323
  return { error: 'Task trigger type is required (use trigger.type or trigger_type).' };
2313
2324
  }
2314
- if (!resolvedTrigger.hasConfig || resolvedTrigger.triggerConfig === undefined) {
2325
+ const normalizedTriggerType = String(resolvedTrigger.triggerType || '').trim();
2326
+ const triggerConfig = (!resolvedTrigger.hasConfig || resolvedTrigger.triggerConfig === undefined)
2327
+ ? (normalizedTriggerType === 'manual' ? {} : undefined)
2328
+ : resolvedTrigger.triggerConfig;
2329
+ if (triggerConfig === undefined) {
2315
2330
  return { error: 'Task trigger config is required (use trigger.config or trigger_config).' };
2316
2331
  }
2317
2332
  const task = await s.createTask(userId, {
2318
2333
  name: args.name,
2319
- triggerType: resolvedTrigger.triggerType,
2320
- triggerConfig: resolvedTrigger.triggerConfig,
2334
+ triggerType: normalizedTriggerType,
2335
+ triggerConfig,
2321
2336
  prompt: args.prompt,
2322
2337
  enabled: args.enabled !== false,
2323
2338
  model: args.model || null,
@@ -2580,6 +2595,20 @@ async function executeTool(toolName, args, context, engine) {
2580
2595
  }
2581
2596
  }
2582
2597
 
2598
+ case 'ocr_extract': {
2599
+ try {
2600
+ const fs = require('fs');
2601
+ if (!fs.existsSync(args.image_path)) {
2602
+ return { error: 'File not found: ' + args.image_path };
2603
+ }
2604
+ const Tesseract = require('tesseract.js');
2605
+ const result = await Tesseract.recognize(args.image_path, 'eng');
2606
+ return { text: result.data.text, confidence: result.data.confidence };
2607
+ } catch (err) {
2608
+ return { error: err.message };
2609
+ }
2610
+ }
2611
+
2583
2612
  case 'spawn_subagent': {
2584
2613
  try {
2585
2614
  const task = args.context ? `${args.task}\n\nContext: ${args.context}` : args.task;
@@ -6,11 +6,11 @@ const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
6
6
  if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
7
7
 
8
8
  const USER_AGENTS = [
9
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
10
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
9
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
10
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
11
11
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
12
12
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
13
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
13
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
14
14
  ];
15
15
 
16
16
  const VIEWPORTS = [
@@ -148,6 +148,50 @@ class BrowserController {
148
148
  return arr;
149
149
  }
150
150
  });
151
+
152
+ // WebGL Spoofing
153
+ const getParameterProxyHandler = {
154
+ apply: function(target, ctx, args) {
155
+ const param = args[0];
156
+ // UNMASKED_VENDOR_WEBGL
157
+ if (param === 37445) return 'Google Inc. (Apple)';
158
+ // UNMASKED_RENDERER_WEBGL
159
+ if (param === 37446) return 'ANGLE (Apple, Apple M2, OpenGL 4.1)';
160
+ return Reflect.apply(target, ctx, args);
161
+ }
162
+ };
163
+ const getParam = WebGLRenderingContext.prototype.getParameter;
164
+ WebGLRenderingContext.prototype.getParameter = new Proxy(getParam, getParameterProxyHandler);
165
+ if (typeof WebGL2RenderingContext !== 'undefined') {
166
+ const getParam2 = WebGL2RenderingContext.prototype.getParameter;
167
+ WebGL2RenderingContext.prototype.getParameter = new Proxy(getParam2, getParameterProxyHandler);
168
+ }
169
+
170
+ // Canvas Spoofing (slight noise)
171
+ const originalFillText = CanvasRenderingContext2D.prototype.fillText;
172
+ CanvasRenderingContext2D.prototype.fillText = function(...args) {
173
+ if (!this._spoofing_applied) {
174
+ this._spoofing_applied = true;
175
+ const r = Math.random() * 0.0001;
176
+ const g = Math.random() * 0.0001;
177
+ const b = Math.random() * 0.0001;
178
+ this.fillStyle = \`rgba(\${Math.floor(r * 255)}, \${Math.floor(g * 255)}, \${Math.floor(b * 255)}, 0.01)\`;
179
+ originalFillText.call(this, "spoof", 0, 0);
180
+ }
181
+ return originalFillText.apply(this, args);
182
+ };
183
+
184
+ // Media Devices Spoofing
185
+ if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
186
+ const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
187
+ navigator.mediaDevices.enumerateDevices = async () => {
188
+ return [
189
+ { kind: 'audioinput', deviceId: 'default', groupId: 'a', label: 'MacBook Pro Microphone' },
190
+ { kind: 'audiooutput', deviceId: 'default', groupId: 'b', label: 'MacBook Pro Speakers' },
191
+ { kind: 'videoinput', deviceId: 'default', groupId: 'c', label: 'FaceTime HD Camera' }
192
+ ];
193
+ };
194
+ }
151
195
  })();
152
196
  `);
153
197
  }
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { exec } = require('child_process');
7
+ const { promisify } = require('util');
8
+ const tesseract = require('tesseract.js');
9
+ const db = require('../../db/database');
10
+ const { getErrorMessage } = require('../bootstrap_helpers');
11
+
12
+ const execAsync = promisify(exec);
13
+
14
+ class ScreenRecorder {
15
+ constructor() {
16
+ this.intervalMs = 10000; // 10 seconds
17
+ this.intervalId = null;
18
+ this.cleanupIntervalId = null;
19
+ this.isRecording = false;
20
+ this.isProcessing = false;
21
+ this.tempFilePath = path.join(os.tmpdir(), `neoagent-screen-${Date.now()}.png`);
22
+ }
23
+
24
+ start() {
25
+ if (process.platform !== 'darwin') {
26
+ console.log('[ScreenRecorder] Not starting: Screen recording is currently macOS only.');
27
+ return;
28
+ }
29
+
30
+ if (this.isRecording) return;
31
+ this.isRecording = true;
32
+
33
+ console.log('[ScreenRecorder] Starting continuous screen recording (10s interval)');
34
+
35
+ // Start the recording loop
36
+ this.intervalId = setInterval(() => this.captureAndProcess(), this.intervalMs);
37
+
38
+ // Run an initial capture
39
+ this.captureAndProcess();
40
+
41
+ // Start daily cleanup of old records (7 days)
42
+ this.cleanupIntervalId = setInterval(() => this.cleanupOldRecords(), 24 * 60 * 60 * 1000);
43
+ this.cleanupOldRecords();
44
+ }
45
+
46
+ stop() {
47
+ this.isRecording = false;
48
+ if (this.intervalId) {
49
+ clearInterval(this.intervalId);
50
+ this.intervalId = null;
51
+ }
52
+ if (this.cleanupIntervalId) {
53
+ clearInterval(this.cleanupIntervalId);
54
+ this.cleanupIntervalId = null;
55
+ }
56
+ console.log('[ScreenRecorder] Stopped continuous screen recording');
57
+ }
58
+
59
+ async captureAndProcess() {
60
+ if (this.isProcessing || !this.isRecording) return;
61
+ this.isProcessing = true;
62
+
63
+ try {
64
+ // Capture screen silently (-x) to file
65
+ await execAsync(`screencapture -x "${this.tempFilePath}"`);
66
+
67
+ // Verify file exists
68
+ await fs.access(this.tempFilePath);
69
+
70
+ // Extract text via local OCR
71
+ const { data } = await tesseract.recognize(this.tempFilePath, 'eng+deu', {
72
+ logger: () => {} // Silence verbose OCR logs
73
+ });
74
+
75
+ const textContent = data.text.trim();
76
+
77
+ // Only store if meaningful text was found
78
+ if (textContent.length > 5) {
79
+ // We need a user ID. For the local desktop agent, usually user 1 or we query the active user.
80
+ const userRow = db.prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
81
+ if (userRow) {
82
+ // Identify the active foreground app via AppleScript
83
+ let appName = 'Unknown';
84
+ try {
85
+ const { stdout } = await execAsync(`osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`);
86
+ appName = stdout.trim();
87
+ } catch (e) {
88
+ // Ignore AppleScript errors
89
+ }
90
+
91
+ db.prepare(\`
92
+ INSERT INTO screen_history (user_id, app_name, text_content)
93
+ VALUES (?, ?, ?)
94
+ \`).run(userRow.id, appName, textContent);
95
+ }
96
+ }
97
+
98
+ } catch (err) {
99
+ console.error('[ScreenRecorder] Capture/OCR failed:', getErrorMessage(err));
100
+ } finally {
101
+ // Always cleanup the screenshot image immediately
102
+ try {
103
+ await fs.unlink(this.tempFilePath);
104
+ } catch (e) {
105
+ // Ignore unlink errors if file didn't exist
106
+ }
107
+ this.isProcessing = false;
108
+ }
109
+ }
110
+
111
+ cleanupOldRecords() {
112
+ try {
113
+ const result = db.prepare(\`
114
+ DELETE FROM screen_history
115
+ WHERE timestamp < datetime('now', '-7 days')
116
+ \`).run();
117
+ if (result.changes > 0) {
118
+ console.log(\`[ScreenRecorder] Purged \${result.changes} old screen history records.\`);
119
+ }
120
+ } catch (err) {
121
+ console.error('[ScreenRecorder] Cleanup failed:', getErrorMessage(err));
122
+ }
123
+ }
124
+ }
125
+
126
+ module.exports = { ScreenRecorder };
@@ -545,7 +545,7 @@ class WhatsAppPersonalProvider extends EventEmitter {
545
545
  chatCount: client.chats.size,
546
546
  cachedMessageChats: client.messages.size,
547
547
  syncNote:
548
- 'Read tools only expose chats and messages synchronized through this personal integration session.',
548
+ 'This account is linked and connected. Read tools expose only chats and messages currently available in this session cache.',
549
549
  },
550
550
  };
551
551
  case 'whatsapp_personal_list_chats': {
@@ -557,10 +557,14 @@ class WhatsAppPersonalProvider extends EventEmitter {
557
557
  .slice(0, limit);
558
558
  return {
559
559
  result: {
560
+ account: client.accountEmail || connection.account_email || null,
561
+ connected: client.status === 'connected',
560
562
  chats,
561
563
  count: chats.length,
562
564
  syncNote:
563
- 'Only chats synchronized after linking this personal integration are available here.',
565
+ chats.length > 0
566
+ ? 'Chats shown here come from the current personal WhatsApp cache.'
567
+ : 'The personal WhatsApp account is connected, but no chats are cached yet. The cache populates as history/events are synchronized.',
564
568
  },
565
569
  };
566
570
  }
@@ -24,6 +24,7 @@ const { RuntimeManager } = require('./runtime/manager');
24
24
  const { BrowserExtensionRegistry } = require('./browser/extension/registry');
25
25
  const { DesktopCompanionRegistry } = require('./desktop/registry');
26
26
  const { DesktopProvider } = require('./desktop/provider');
27
+ const { ScreenRecorder } = require('./desktop/screenRecorder');
27
28
  const { assertRuntimeValidation, getRuntimeValidation } = require('./runtime/validation');
28
29
  const {
29
30
  getErrorMessage,
@@ -428,6 +429,17 @@ function createWidgetService(app) {
428
429
  return widgetService;
429
430
  }
430
431
 
432
+ function createScreenRecorder(app) {
433
+ const screenRecorder = registerLocal(
434
+ app,
435
+ 'screenRecorder',
436
+ new ScreenRecorder(),
437
+ );
438
+ screenRecorder.start();
439
+ logServiceReady('Screen recorder started');
440
+ return screenRecorder;
441
+ }
442
+
431
443
  function restoreMessagingConnections(messagingManager) {
432
444
  void runBackgroundTask('[Messaging] Restore error:', () =>
433
445
  messagingManager.restoreConnections(),
@@ -513,6 +525,7 @@ async function startServices(app, io) {
513
525
  const messagingManager = createMessagingManager(app, io, agentEngine);
514
526
  const recordingManager = createRecordingManager(app, io);
515
527
  createWidgetService(app);
528
+ createScreenRecorder(app);
516
529
 
517
530
  restoreMessagingConnections(messagingManager);
518
531
  restoreMcpClients(mcpClient);
@@ -639,6 +652,15 @@ async function stopServices(app) {
639
652
  }
640
653
  }
641
654
 
655
+ if (app.locals.screenRecorder) {
656
+ try {
657
+ app.locals.screenRecorder.stop();
658
+ logServiceReady('Screen recorder stopped');
659
+ } catch (err) {
660
+ console.error('[ScreenRecorder] Stop error:', getErrorMessage(err));
661
+ }
662
+ }
663
+
642
664
  if (app.locals.browserExtensionRegistry) {
643
665
  try {
644
666
  app.locals.browserExtensionRegistry.closeAll();
@@ -1161,6 +1161,39 @@ Auto-orients and arranges the STL, applies settings from JSON files, slices all
1161
1161
  3. Build the command from the flags above
1162
1162
  4. Run it and check for errors in the output (debug level 2 is a good default)
1163
1163
  5. Report the output file location and any warnings`
1164
+ },
1165
+
1166
+ {
1167
+ id: 'package-tracker',
1168
+ name: 'Package Tracker',
1169
+ description: 'Track packages by automatically detecting the carrier and fetching the tracking link.',
1170
+ category: 'info',
1171
+ icon: '📦',
1172
+ content: `---
1173
+ name: package-tracker
1174
+ description: Track packages by automatically detecting the carrier and fetching the tracking link.
1175
+ category: info
1176
+ icon: 📦
1177
+ enabled: true
1178
+ ---
1179
+
1180
+ You are a package tracking assistant. When the user gives you a tracking number, you should:
1181
+
1182
+ 1. Identify the carrier using common regex patterns:
1183
+ - UPS: \`\\b(1Z[0-9A-Z]{16})\\b\`
1184
+ - FedEx: \`\\b([0-9]{12,15})\\b\` (usually 12 or 15 digits)
1185
+ - USPS: \`\\b(94[0-9]{20})\\b\` or \`\\b([A-Z]{2}[0-9]{9}US)\\b\`
1186
+ - DHL: \`\\b([0-9]{10})\\b\` or \`\\b([0-9]{20})\\b\`
1187
+
1188
+ 2. Generate the direct tracking link for the user:
1189
+ - UPS: \`https://www.ups.com/track?tracknum={number}\`
1190
+ - FedEx: \`https://www.fedex.com/fedextrack/?trknbr={number}\`
1191
+ - USPS: \`https://tools.usps.com/go/TrackConfirmAction?tLabels={number}\`
1192
+ - DHL: \`https://www.dhl.com/global-en/home/tracking/tracking-express.html?submit=1&tracking-id={number}\`
1193
+
1194
+ 3. If the carrier is not obvious, use \`web_search\` with the query "track package {number}" to identify the carrier.
1195
+ 4. Present the carrier and the direct tracking link to the user clearly.
1196
+ 5. Optionally use \`browser_navigate\` or \`http_request\` to fetch the current status from the tracking link, but note that many carriers block automated scraping. The direct link is the most important output.`
1164
1197
  }
1165
1198
  ];
1166
1199
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  module.exports = [
4
4
  require('./schedule'),
5
+ require('./manual'),
5
6
  require('./gmail_message_received'),
6
7
  require('./outlook_email_received'),
7
8
  require('./slack_message_received'),
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ type: 'manual',
5
+ label: 'Manual Trigger',
6
+ async validateConfig() {
7
+ return {};
8
+ },
9
+ summarize() {
10
+ return 'Manual run only';
11
+ },
12
+ };
@@ -66,7 +66,7 @@ class TaskRuntime {
66
66
  label: adapter.label,
67
67
  providerKey: adapter.providerKey || null,
68
68
  appKey: adapter.appKey || null,
69
- available: adapter.type === 'schedule'
69
+ available: adapter.type === 'schedule' || adapter.type === 'manual'
70
70
  ? true
71
71
  : this._hasConnectedApp(userId, agentId, adapter.providerKey, adapter.appKey),
72
72
  }));
@@ -243,7 +243,11 @@ class WidgetService {
243
243
  WHERE id = ? AND user_id = ?`
244
244
  ).get(widgetId, userId);
245
245
  if (!row) return null;
246
- return this._serializeWidget(row, this._loadLatestSnapshotMap([widgetId]).get(widgetId) || null);
246
+ return this._serializeWidget(
247
+ row,
248
+ this._loadLatestSnapshotMap([widgetId]).get(widgetId) || null,
249
+ this._loadWidgetTasksMap([widgetId], userId).get(widgetId) || []
250
+ );
247
251
  }
248
252
 
249
253
  listWidgets(userId, { agentId = null } = {}) {
@@ -262,7 +266,8 @@ class WidgetService {
262
266
  ORDER BY updated_at DESC, created_at DESC`
263
267
  ).all(userId);
264
268
  const snapshotMap = this._loadLatestSnapshotMap(rows.map((row) => row.id));
265
- return rows.map((row) => this._serializeWidget(row, snapshotMap.get(row.id) || null));
269
+ const tasksMap = this._loadWidgetTasksMap(rows.map((row) => row.id), userId);
270
+ return rows.map((row) => this._serializeWidget(row, snapshotMap.get(row.id) || null, tasksMap.get(row.id) || []));
266
271
  }
267
272
 
268
273
  listLatestSnapshots(userId, { agentId = null } = {}) {
@@ -343,7 +348,7 @@ class WidgetService {
343
348
  throw new Error('Widget not found.');
344
349
  }
345
350
 
346
- const current = this._serializeWidget(existingRow, null);
351
+ const current = this._serializeWidget(existingRow, null, []);
347
352
  const normalized = normalizeWidgetInput({
348
353
  name: input.name ?? current.name,
349
354
  template: input.template ?? current.template,
@@ -581,7 +586,46 @@ class WidgetService {
581
586
  return map;
582
587
  }
583
588
 
584
- _serializeWidget(row, latestSnapshot) {
589
+ _loadWidgetTasksMap(widgetIds, userId) {
590
+ const ids = Array.from(new Set(widgetIds.filter(Boolean)));
591
+ const map = new Map();
592
+ if (!ids.length) return map;
593
+
594
+ const placeholders = ids.map(() => '?').join(', ');
595
+ const params = [userId, ...ids];
596
+
597
+ // We filter tasks where task_type is NOT 'widget_refresh'
598
+ // and where the task_config contains the widgetId.
599
+ const rows = db.prepare(
600
+ `SELECT id, name, trigger_type, enabled, task_config
601
+ FROM scheduled_tasks
602
+ WHERE user_id = ?
603
+ AND task_type != 'widget_refresh'
604
+ AND json_extract(task_config, '$.widgetId') IN (${placeholders})
605
+ ORDER BY created_at ASC`
606
+ ).all(...params);
607
+
608
+ for (const row of rows) {
609
+ const config = parseJsonObject(row.task_config, {});
610
+ const widgetId = config.widgetId;
611
+ if (!widgetId) continue;
612
+
613
+ const task = {
614
+ id: row.id,
615
+ name: row.name,
616
+ triggerType: row.trigger_type,
617
+ enabled: row.enabled !== 0 && row.enabled !== false,
618
+ };
619
+
620
+ if (!map.has(widgetId)) {
621
+ map.set(widgetId, []);
622
+ }
623
+ map.get(widgetId).push(task);
624
+ }
625
+ return map;
626
+ }
627
+
628
+ _serializeWidget(row, latestSnapshot, tasks = []) {
585
629
  const definition = parseJsonObject(row.definition_json, {});
586
630
  return {
587
631
  id: row.id,
@@ -600,6 +644,7 @@ class WidgetService {
600
644
  updatedAt: row.updated_at || null,
601
645
  nextRefresh: row.refresh_cron ? findNextRun(row.refresh_cron)?.toISOString() || null : null,
602
646
  latestSnapshot,
647
+ tasks,
603
648
  };
604
649
  }
605
650
  }