neoagent 2.2.0 → 2.2.1-beta.1

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.
@@ -0,0 +1,550 @@
1
+ const crypto = require('crypto');
2
+ const db = require('../../db/database');
3
+ const { resolveAgentId } = require('../agents/manager');
4
+ const { getMinimumIntervalMinutes } = require('../scheduler/cron_utils');
5
+
6
+ const MIN_WIDGET_REFRESH_MINUTES = 60;
7
+
8
+ const TEMPLATE_VARIANTS = {
9
+ stat: ['hero', 'split', 'compact'],
10
+ summary: ['stack', 'banner', 'focus'],
11
+ list: ['agenda', 'compact', 'split'],
12
+ };
13
+
14
+ function parseJsonObject(value, fallback = {}) {
15
+ if (!value) return { ...fallback };
16
+ if (typeof value === 'object' && !Array.isArray(value)) return { ...value };
17
+ try {
18
+ const parsed = JSON.parse(String(value));
19
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
20
+ ? parsed
21
+ : { ...fallback };
22
+ } catch {
23
+ return { ...fallback };
24
+ }
25
+ }
26
+
27
+ function normalizeText(value, maxLength = 4000) {
28
+ return String(value || '').trim().slice(0, maxLength);
29
+ }
30
+
31
+ function normalizeOptionalText(value, maxLength = 4000) {
32
+ const normalized = normalizeText(value, maxLength);
33
+ return normalized || null;
34
+ }
35
+
36
+ function buildWidgetRefreshTaskName(name) {
37
+ return `Refresh widget: ${normalizeText(name, 120)}`;
38
+ }
39
+
40
+ function templateVariants(template) {
41
+ return TEMPLATE_VARIANTS[template] || [];
42
+ }
43
+
44
+ function normalizeDefinition(input = {}) {
45
+ const raw = parseJsonObject(input, {});
46
+ const prompt = normalizeText(raw.prompt || raw.refreshPrompt || raw.goal || '', 12000);
47
+ if (!prompt) {
48
+ throw new Error('Widget definition requires a prompt.');
49
+ }
50
+
51
+ return {
52
+ prompt,
53
+ description: normalizeOptionalText(raw.description, 500),
54
+ systemHint: normalizeOptionalText(raw.systemHint, 1000),
55
+ emptyState: normalizeOptionalText(raw.emptyState, 200),
56
+ };
57
+ }
58
+
59
+ function validateRefreshCron(refreshCron) {
60
+ const cronExpression = normalizeText(refreshCron, 120);
61
+ if (!cronExpression) {
62
+ throw new Error('refreshCron is required.');
63
+ }
64
+ const minInterval = getMinimumIntervalMinutes(cronExpression, 4);
65
+ if (minInterval != null && minInterval < MIN_WIDGET_REFRESH_MINUTES) {
66
+ throw new Error('Widget refresh cadence must be at least 1 hour.');
67
+ }
68
+ return cronExpression;
69
+ }
70
+
71
+ function normalizeWidgetInput(input = {}, userId) {
72
+ const name = normalizeText(input.name, 120);
73
+ if (!name) {
74
+ throw new Error('Widget name is required.');
75
+ }
76
+
77
+ const template = normalizeText(input.template, 40).toLowerCase();
78
+ if (!Object.prototype.hasOwnProperty.call(TEMPLATE_VARIANTS, template)) {
79
+ throw new Error(`Unsupported widget template "${template}".`);
80
+ }
81
+
82
+ const layoutVariant = normalizeText(input.layoutVariant || input.layout_variant, 40).toLowerCase();
83
+ if (!templateVariants(template).includes(layoutVariant)) {
84
+ throw new Error(`Unsupported layout variant "${layoutVariant}" for template "${template}".`);
85
+ }
86
+
87
+ const refreshCron = validateRefreshCron(input.refreshCron || input.refresh_cron);
88
+ const agentId = resolveAgentId(userId, input.agentId || input.agent_id || null);
89
+
90
+ return {
91
+ name,
92
+ template,
93
+ layoutVariant,
94
+ refreshCron,
95
+ enabled: input.enabled !== false,
96
+ definition: normalizeDefinition(input.definition || input.definition_json || {
97
+ prompt: input.prompt || input.refreshPrompt || input.refresh_prompt || '',
98
+ description: input.description || '',
99
+ }),
100
+ agentId,
101
+ };
102
+ }
103
+
104
+ function normalizeTrend(input) {
105
+ if (input == null) return null;
106
+ if (typeof input === 'string') {
107
+ const label = normalizeText(input, 80);
108
+ return label ? { label, direction: 'flat' } : null;
109
+ }
110
+ const raw = parseJsonObject(input, {});
111
+ const label = normalizeText(raw.label, 80);
112
+ if (!label) return null;
113
+ const direction = ['up', 'down', 'flat'].includes(String(raw.direction || '').trim().toLowerCase())
114
+ ? String(raw.direction).trim().toLowerCase()
115
+ : 'flat';
116
+ return { label, direction };
117
+ }
118
+
119
+ function normalizeRows(input) {
120
+ if (!Array.isArray(input)) return [];
121
+ return input
122
+ .slice(0, 3)
123
+ .map((row) => {
124
+ if (typeof row === 'string') {
125
+ const value = normalizeText(row, 140);
126
+ if (!value) return null;
127
+ return { label: value, value: '' };
128
+ }
129
+ const raw = parseJsonObject(row, {});
130
+ const label = normalizeText(raw.label, 60);
131
+ const value = normalizeText(raw.value, 120);
132
+ if (!label && !value) return null;
133
+ return {
134
+ label: label || value,
135
+ value: value || label,
136
+ };
137
+ })
138
+ .filter(Boolean);
139
+ }
140
+
141
+ function normalizeChips(input) {
142
+ if (!Array.isArray(input)) return [];
143
+ return input
144
+ .slice(0, 3)
145
+ .map((chip) => normalizeText(chip, 40))
146
+ .filter(Boolean);
147
+ }
148
+
149
+ function serializeSnapshotRow(row) {
150
+ if (!row) return null;
151
+ const payload = parseJsonObject(row.payload_json, {});
152
+ return {
153
+ id: row.id,
154
+ widgetId: row.widget_id,
155
+ payload,
156
+ generatedAt: row.generated_at,
157
+ sourceRunId: row.source_run_id || null,
158
+ status: row.status || 'ready',
159
+ };
160
+ }
161
+
162
+ function validateSnapshotPayload(widget, snapshot = {}) {
163
+ const payload = parseJsonObject(snapshot, {});
164
+ const title = normalizeText(payload.title, 120);
165
+ if (!title) {
166
+ throw new Error('Widget snapshots require a title.');
167
+ }
168
+
169
+ return {
170
+ template: widget.template,
171
+ layoutVariant: widget.layoutVariant,
172
+ title,
173
+ subtitle: normalizeOptionalText(payload.subtitle, 160),
174
+ body: normalizeOptionalText(payload.body, 600),
175
+ metric: normalizeOptionalText(payload.metric, 64),
176
+ trend: normalizeTrend(payload.trend),
177
+ rows: normalizeRows(payload.rows),
178
+ chips: normalizeChips(payload.chips),
179
+ iconToken: normalizeOptionalText(payload.iconToken, 40),
180
+ accentToken: normalizeOptionalText(payload.accentToken, 40),
181
+ updatedAt: normalizeOptionalText(payload.updatedAt, 80) || new Date().toISOString(),
182
+ deepLink: normalizeOptionalText(payload.deepLink, 200) || `widget:${widget.id}`,
183
+ };
184
+ }
185
+
186
+ class WidgetService {
187
+ constructor({ app }) {
188
+ this.app = app;
189
+ }
190
+
191
+ get scheduler() {
192
+ return this.app?.locals?.scheduler || null;
193
+ }
194
+
195
+ get agentEngine() {
196
+ return this.app?.locals?.agentEngine || null;
197
+ }
198
+
199
+ getWidget(userId, widgetId) {
200
+ const row = db.prepare(
201
+ `SELECT *
202
+ FROM ai_widgets
203
+ WHERE id = ? AND user_id = ?`
204
+ ).get(widgetId, userId);
205
+ if (!row) return null;
206
+ return this._serializeWidget(row, this._loadLatestSnapshotMap([widgetId]).get(widgetId) || null);
207
+ }
208
+
209
+ listWidgets(userId, { agentId = null } = {}) {
210
+ const scopedAgentId = agentId ? resolveAgentId(userId, agentId) : null;
211
+ const rows = scopedAgentId
212
+ ? db.prepare(
213
+ `SELECT *
214
+ FROM ai_widgets
215
+ WHERE user_id = ? AND agent_id = ?
216
+ ORDER BY updated_at DESC, created_at DESC`
217
+ ).all(userId, scopedAgentId)
218
+ : db.prepare(
219
+ `SELECT *
220
+ FROM ai_widgets
221
+ WHERE user_id = ?
222
+ ORDER BY updated_at DESC, created_at DESC`
223
+ ).all(userId);
224
+ const snapshotMap = this._loadLatestSnapshotMap(rows.map((row) => row.id));
225
+ return rows.map((row) => this._serializeWidget(row, snapshotMap.get(row.id) || null));
226
+ }
227
+
228
+ listLatestSnapshots(userId, { agentId = null } = {}) {
229
+ return this.listWidgets(userId, { agentId })
230
+ .map((widget) => widget.latestSnapshot)
231
+ .filter(Boolean);
232
+ }
233
+
234
+ createWidget(userId, input = {}) {
235
+ const normalized = normalizeWidgetInput(input, userId);
236
+ const scheduler = this.scheduler;
237
+ if (!scheduler) {
238
+ throw new Error('Scheduler not available.');
239
+ }
240
+ const widgetId = crypto.randomUUID();
241
+
242
+ const tx = db.transaction(() => {
243
+ db.prepare(
244
+ `INSERT INTO ai_widgets (
245
+ id, user_id, agent_id, name, template, layout_variant, definition_json,
246
+ refresh_cron, enabled, created_at, updated_at
247
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`
248
+ ).run(
249
+ widgetId,
250
+ userId,
251
+ normalized.agentId,
252
+ normalized.name,
253
+ normalized.template,
254
+ normalized.layoutVariant,
255
+ JSON.stringify(normalized.definition),
256
+ normalized.refreshCron,
257
+ normalized.enabled ? 1 : 0,
258
+ );
259
+ });
260
+
261
+ tx();
262
+
263
+ let task;
264
+ try {
265
+ task = scheduler.createTask(userId, {
266
+ name: buildWidgetRefreshTaskName(normalized.name),
267
+ cronExpression: normalized.refreshCron,
268
+ enabled: normalized.enabled,
269
+ agentId: normalized.agentId,
270
+ taskType: 'widget_refresh',
271
+ taskConfig: { widgetId },
272
+ });
273
+ } catch (error) {
274
+ db.prepare('DELETE FROM ai_widgets WHERE id = ? AND user_id = ?').run(widgetId, userId);
275
+ throw error;
276
+ }
277
+
278
+ try {
279
+ db.prepare(
280
+ `UPDATE ai_widgets
281
+ SET scheduled_task_id = ?, updated_at = datetime('now')
282
+ WHERE id = ? AND user_id = ?`
283
+ ).run(task.id, widgetId, userId);
284
+ } catch (error) {
285
+ try {
286
+ scheduler.deleteTask(task.id, userId, { allowManaged: true });
287
+ } catch {
288
+ // Ignore cleanup failures and rethrow the original DB error.
289
+ }
290
+ throw error;
291
+ }
292
+
293
+ return this.getWidget(userId, widgetId);
294
+ }
295
+
296
+ updateWidget(userId, widgetId, input = {}) {
297
+ const existingRow = db.prepare('SELECT * FROM ai_widgets WHERE id = ? AND user_id = ?').get(widgetId, userId);
298
+ if (!existingRow) {
299
+ throw new Error('Widget not found.');
300
+ }
301
+
302
+ const current = this._serializeWidget(existingRow, null);
303
+ const normalized = normalizeWidgetInput({
304
+ name: input.name ?? current.name,
305
+ template: input.template ?? current.template,
306
+ layoutVariant: input.layoutVariant ?? input.layout_variant ?? current.layoutVariant,
307
+ refreshCron: input.refreshCron ?? input.refresh_cron ?? current.refreshCron,
308
+ enabled: input.enabled ?? current.enabled,
309
+ agentId: input.agentId ?? input.agent_id ?? current.agentId,
310
+ definition: input.definition ?? input.definition_json ?? current.definition,
311
+ prompt: input.prompt,
312
+ refreshPrompt: input.refreshPrompt ?? input.refresh_prompt,
313
+ description: input.description,
314
+ }, userId);
315
+
316
+ const scheduler = this.scheduler;
317
+ if (!scheduler) {
318
+ throw new Error('Scheduler not available.');
319
+ }
320
+
321
+ db.prepare('BEGIN').run();
322
+ try {
323
+ db.prepare(
324
+ `UPDATE ai_widgets
325
+ SET agent_id = ?, name = ?, template = ?, layout_variant = ?, definition_json = ?,
326
+ refresh_cron = ?, enabled = ?, updated_at = datetime('now')
327
+ WHERE id = ? AND user_id = ?`
328
+ ).run(
329
+ normalized.agentId,
330
+ normalized.name,
331
+ normalized.template,
332
+ normalized.layoutVariant,
333
+ JSON.stringify(normalized.definition),
334
+ normalized.refreshCron,
335
+ normalized.enabled ? 1 : 0,
336
+ widgetId,
337
+ userId,
338
+ );
339
+
340
+ if (existingRow.scheduled_task_id) {
341
+ scheduler.updateTask(
342
+ existingRow.scheduled_task_id,
343
+ userId,
344
+ {
345
+ name: buildWidgetRefreshTaskName(normalized.name),
346
+ cronExpression: normalized.refreshCron,
347
+ enabled: normalized.enabled,
348
+ agentId: normalized.agentId,
349
+ taskConfig: { widgetId },
350
+ },
351
+ { allowManaged: true },
352
+ );
353
+ } else {
354
+ const task = scheduler.createTask(userId, {
355
+ name: buildWidgetRefreshTaskName(normalized.name),
356
+ cronExpression: normalized.refreshCron,
357
+ enabled: normalized.enabled,
358
+ agentId: normalized.agentId,
359
+ taskType: 'widget_refresh',
360
+ taskConfig: { widgetId },
361
+ });
362
+ db.prepare(
363
+ `UPDATE ai_widgets
364
+ SET scheduled_task_id = ?, updated_at = datetime('now')
365
+ WHERE id = ? AND user_id = ?`
366
+ ).run(task.id, widgetId, userId);
367
+ }
368
+
369
+ db.prepare('COMMIT').run();
370
+ } catch (error) {
371
+ try {
372
+ db.prepare('ROLLBACK').run();
373
+ } catch {
374
+ // Ignore rollback failures and rethrow the original scheduler/DB error.
375
+ }
376
+ throw error;
377
+ }
378
+
379
+ return this.getWidget(userId, widgetId);
380
+ }
381
+
382
+ deleteWidget(userId, widgetId) {
383
+ const existingRow = db.prepare('SELECT * FROM ai_widgets WHERE id = ? AND user_id = ?').get(widgetId, userId);
384
+ if (!existingRow) {
385
+ throw new Error('Widget not found.');
386
+ }
387
+
388
+ const scheduler = this.scheduler;
389
+ const tx = db.transaction(() => {
390
+ if (existingRow.scheduled_task_id && scheduler) {
391
+ scheduler.deleteTask(existingRow.scheduled_task_id, userId, { allowManaged: true });
392
+ }
393
+ db.prepare('DELETE FROM ai_widget_snapshots WHERE widget_id = ?').run(widgetId);
394
+ db.prepare('DELETE FROM ai_widgets WHERE id = ? AND user_id = ?').run(widgetId, userId);
395
+ });
396
+ tx();
397
+ return { deleted: true };
398
+ }
399
+
400
+ saveSnapshot(userId, widgetId, snapshot, { sourceRunId = null, status = 'ready' } = {}) {
401
+ const widget = this.getWidget(userId, widgetId);
402
+ if (!widget) {
403
+ throw new Error('Widget not found.');
404
+ }
405
+
406
+ const payload = validateSnapshotPayload(widget, snapshot);
407
+ const snapshotId = db.prepare(
408
+ `INSERT INTO ai_widget_snapshots (
409
+ widget_id, payload_json, generated_at, source_run_id, status
410
+ ) VALUES (?, ?, datetime('now'), ?, ?)`
411
+ ).run(widgetId, JSON.stringify(payload), sourceRunId, status).lastInsertRowid;
412
+
413
+ db.prepare(
414
+ `UPDATE ai_widgets
415
+ SET last_snapshot_at = datetime('now'), last_error = NULL, updated_at = datetime('now')
416
+ WHERE id = ? AND user_id = ?`
417
+ ).run(widgetId, userId);
418
+
419
+ const row = db.prepare('SELECT * FROM ai_widget_snapshots WHERE id = ?').get(snapshotId);
420
+ return serializeSnapshotRow(row);
421
+ }
422
+
423
+ setWidgetError(userId, widgetId, message) {
424
+ db.prepare(
425
+ `UPDATE ai_widgets
426
+ SET last_error = ?, updated_at = datetime('now')
427
+ WHERE id = ? AND user_id = ?`
428
+ ).run(normalizeText(message, 500), widgetId, userId);
429
+ }
430
+
431
+ async refreshWidget(userId, widgetId, options = {}) {
432
+ const widget = this.getWidget(userId, widgetId);
433
+ if (!widget) {
434
+ throw new Error('Widget not found.');
435
+ }
436
+ if (!widget.enabled) {
437
+ return { skipped: true, reason: 'disabled' };
438
+ }
439
+ const engine = this.agentEngine;
440
+ if (!engine) {
441
+ throw new Error('Agent engine not available.');
442
+ }
443
+
444
+ try {
445
+ const prompt = this._buildRefreshPrompt(widget);
446
+ const result = await engine.run(userId, prompt, {
447
+ triggerType: 'scheduler',
448
+ triggerSource: 'scheduler',
449
+ agentId: widget.agentId,
450
+ app: this.app,
451
+ taskId: options.taskId || widget.scheduledTaskId || null,
452
+ widgetId: widget.id,
453
+ skipTaskAnalysis: true,
454
+ skipGlobalRecall: true,
455
+ skipConversationHistory: true,
456
+ skipConversationMaintenance: true,
457
+ skipRunContextPersistence: true,
458
+ skipVerifier: true,
459
+ stream: false,
460
+ });
461
+ if (result?.runId) {
462
+ engine.persistRunMetadata(result.runId, {
463
+ widgetId: widget.id,
464
+ widgetTemplate: widget.template,
465
+ widgetLayoutVariant: widget.layoutVariant,
466
+ });
467
+ }
468
+ return result;
469
+ } catch (error) {
470
+ this.setWidgetError(userId, widgetId, error?.message || 'Widget refresh failed.');
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ _buildRefreshPrompt(widget) {
476
+ const definition = widget.definition || {};
477
+ return [
478
+ '[SYSTEM: Refreshing AI Widget]',
479
+ `Widget ID: ${widget.id}`,
480
+ `Widget name: ${widget.name}`,
481
+ `Template: ${widget.template}`,
482
+ `Layout variant: ${widget.layoutVariant}`,
483
+ '',
484
+ 'You are updating a structured product widget. Keep the layout fixed. Refresh only the content snapshot.',
485
+ 'Use fresh tools for time-sensitive claims. Do not rely on stale memory for live data such as weather, markets, incidents, or schedules.',
486
+ 'After gathering the latest information, call save_widget_snapshot exactly once with a payload matching this schema:',
487
+ '{"title":"","subtitle":"","body":"","metric":"","trend":{"label":"","direction":"flat"},"rows":[{"label":"","value":""}],"chips":[""],"iconToken":"","accentToken":"","updatedAt":"","deepLink":""}',
488
+ 'Rules:',
489
+ '- Do not change the template or layout variant.',
490
+ '- Keep rows to at most 3 and chips to at most 3.',
491
+ '- If the data source fails, explain the problem briefly in body and still save a truthful degraded snapshot if possible.',
492
+ '- If nothing useful can be produced safely, say so clearly instead of inventing content.',
493
+ '',
494
+ 'Widget definition:',
495
+ definition.prompt || '',
496
+ definition.systemHint ? `\nExtra guidance:\n${definition.systemHint}` : '',
497
+ ].join('\n');
498
+ }
499
+
500
+ _loadLatestSnapshotMap(widgetIds) {
501
+ const ids = Array.from(new Set(widgetIds.filter(Boolean)));
502
+ const map = new Map();
503
+ if (!ids.length) return map;
504
+ const placeholders = ids.map(() => '?').join(', ');
505
+ const rows = db.prepare(
506
+ `SELECT s.*
507
+ FROM ai_widget_snapshots s
508
+ INNER JOIN (
509
+ SELECT widget_id, MAX(id) AS latest_id
510
+ FROM ai_widget_snapshots
511
+ WHERE widget_id IN (${placeholders})
512
+ GROUP BY widget_id
513
+ ) latest ON latest.latest_id = s.id`
514
+ ).all(...ids);
515
+ for (const row of rows) {
516
+ map.set(row.widget_id, serializeSnapshotRow(row));
517
+ }
518
+ return map;
519
+ }
520
+
521
+ _serializeWidget(row, latestSnapshot) {
522
+ const definition = parseJsonObject(row.definition_json, {});
523
+ return {
524
+ id: row.id,
525
+ userId: row.user_id,
526
+ agentId: row.agent_id || null,
527
+ name: row.name,
528
+ template: row.template,
529
+ layoutVariant: row.layout_variant,
530
+ definition,
531
+ refreshCron: row.refresh_cron,
532
+ enabled: row.enabled !== 0 && row.enabled !== false,
533
+ scheduledTaskId: row.scheduled_task_id || null,
534
+ lastSnapshotAt: row.last_snapshot_at || null,
535
+ lastError: row.last_error || null,
536
+ createdAt: row.created_at || null,
537
+ updatedAt: row.updated_at || null,
538
+ nextRefresh: row.refresh_cron ? this.scheduler?._getNextRun?.(row.refresh_cron) || null : null,
539
+ latestSnapshot,
540
+ };
541
+ }
542
+ }
543
+
544
+ module.exports = {
545
+ MIN_WIDGET_REFRESH_MINUTES,
546
+ TEMPLATE_VARIANTS,
547
+ WidgetService,
548
+ buildWidgetRefreshTaskName,
549
+ validateRefreshCron,
550
+ };