neoagent 2.3.0 → 2.3.1-beta.2

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,84 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ ensureOwnedIntegrationConnection,
5
+ normalizeTrimmedText,
6
+ } = require('../security');
7
+
8
+ const WEATHER_EVENT_TYPES = new Set([
9
+ 'rain_start',
10
+ 'snow_start',
11
+ 'wind_alert',
12
+ 'temperature_above',
13
+ 'temperature_below',
14
+ ]);
15
+
16
+ function normalizeNumber(value, fallback) {
17
+ const numeric = Number(value);
18
+ return Number.isFinite(numeric) ? numeric : fallback;
19
+ }
20
+
21
+ function normalizeEventTypes(value) {
22
+ const raw = Array.isArray(value)
23
+ ? value
24
+ : String(value || '')
25
+ .split(',')
26
+ .map((entry) => entry.trim())
27
+ .filter(Boolean);
28
+ const normalized = raw
29
+ .map((entry) => String(entry || '').trim().toLowerCase())
30
+ .filter((entry) => WEATHER_EVENT_TYPES.has(entry));
31
+ return Array.from(new Set(normalized));
32
+ }
33
+
34
+ module.exports = {
35
+ type: 'weather_event',
36
+ label: 'Weather Event',
37
+ providerKey: 'weather',
38
+ appKey: 'forecast',
39
+ async validateConfig(config = {}, context = {}) {
40
+ const connection = ensureOwnedIntegrationConnection(context.integrationManager, {
41
+ userId: context.userId,
42
+ agentId: context.agentId,
43
+ connectionId: config.connectionId || config.connection_id,
44
+ providerKey: 'weather',
45
+ appKey: 'forecast',
46
+ });
47
+
48
+ const eventTypes = normalizeEventTypes(config.eventTypes || config.event_types);
49
+ if (eventTypes.length === 0) {
50
+ throw new Error(
51
+ 'At least one weather event type is required: rain_start, snow_start, wind_alert, temperature_above, temperature_below.',
52
+ );
53
+ }
54
+
55
+ const location = normalizeTrimmedText(
56
+ config.location || config.locationQuery || config.query,
57
+ 180,
58
+ );
59
+ if (!location) {
60
+ throw new Error('Weather event location is required (for example: Berlin, DE).');
61
+ }
62
+
63
+ return {
64
+ connectionId: connection.id,
65
+ accountEmail: connection.account_email || null,
66
+ location,
67
+ eventTypes,
68
+ minPrecipitationMm: normalizeNumber(config.minPrecipitationMm ?? config.min_precipitation_mm, 0.4),
69
+ minSnowfallCm: normalizeNumber(config.minSnowfallCm ?? config.min_snowfall_cm, 0.2),
70
+ windAlertKph: normalizeNumber(config.windAlertKph ?? config.wind_alert_kph, 40),
71
+ temperatureAboveC: normalizeNumber(config.temperatureAboveC ?? config.temperature_above_c, 32),
72
+ temperatureBelowC: normalizeNumber(config.temperatureBelowC ?? config.temperature_below_c, 0),
73
+ horizonHours: Math.max(1, Math.min(Number(config.horizonHours || config.horizon_hours) || 12, 48)),
74
+ };
75
+ },
76
+ summarize(config = {}) {
77
+ const parts = ['Weather'];
78
+ if (config.location) parts.push(config.location);
79
+ if (Array.isArray(config.eventTypes) && config.eventTypes.length > 0) {
80
+ parts.push(config.eventTypes.join(', '));
81
+ }
82
+ return parts.join(' · ');
83
+ },
84
+ };
@@ -8,6 +8,7 @@ const POLLED_TRIGGER_TYPES = Object.freeze([
8
8
  'outlook_email_received',
9
9
  'slack_message_received',
10
10
  'teams_message_received',
11
+ 'weather_event',
11
12
  'whatsapp_personal_message_received',
12
13
  ]);
13
14
 
@@ -166,6 +167,90 @@ async function fetchTriggerRows({ integrationManager, userId, agentId, triggerTy
166
167
  .sort(sortByTimestamp);
167
168
  }
168
169
 
170
+ if (triggerType === 'weather_event') {
171
+ const forecast = await integrationManager.executeTool(userId, 'weather_get_forecast', {
172
+ ...connectionArg,
173
+ ...(config.location ? { location: config.location } : {}),
174
+ forecast_hours: Math.max(1, Math.min(Number(config.horizonHours) || 12, 48)),
175
+ }, scopedAgentId);
176
+ const hourly = Array.isArray(forecast?.hourly) ? forecast.hourly : [];
177
+ const eventTypes = Array.isArray(config.eventTypes) ? config.eventTypes : [];
178
+ const rows = [];
179
+
180
+ for (let index = 0; index < hourly.length; index += 1) {
181
+ const row = hourly[index] || {};
182
+ const previous = index > 0 ? (hourly[index - 1] || {}) : null;
183
+ const time = String(row.time || '').trim();
184
+ if (!time) continue;
185
+
186
+ const rain = Number(row.rain || row.precipitation || 0);
187
+ const prevRain = Number(previous?.rain || previous?.precipitation || 0);
188
+ const snowfall = Number(row.snowfall || 0);
189
+ const prevSnow = Number(previous?.snowfall || 0);
190
+ const windSpeed = Number(row.windSpeed || 0);
191
+ const temperature = Number(row.temperature);
192
+
193
+ const candidates = [
194
+ {
195
+ type: 'rain_start',
196
+ active:
197
+ eventTypes.includes('rain_start')
198
+ && rain >= Number(config.minPrecipitationMm || 0.4)
199
+ && prevRain < Number(config.minPrecipitationMm || 0.4),
200
+ },
201
+ {
202
+ type: 'snow_start',
203
+ active:
204
+ eventTypes.includes('snow_start')
205
+ && snowfall >= Number(config.minSnowfallCm || 0.2)
206
+ && prevSnow < Number(config.minSnowfallCm || 0.2),
207
+ },
208
+ {
209
+ type: 'wind_alert',
210
+ active:
211
+ eventTypes.includes('wind_alert')
212
+ && windSpeed >= Number(config.windAlertKph || 40),
213
+ },
214
+ {
215
+ type: 'temperature_above',
216
+ active:
217
+ eventTypes.includes('temperature_above')
218
+ && Number.isFinite(temperature)
219
+ && temperature >= Number(config.temperatureAboveC || 32),
220
+ },
221
+ {
222
+ type: 'temperature_below',
223
+ active:
224
+ eventTypes.includes('temperature_below')
225
+ && Number.isFinite(temperature)
226
+ && temperature <= Number(config.temperatureBelowC || 0),
227
+ },
228
+ ];
229
+
230
+ for (const candidate of candidates) {
231
+ if (!candidate.active) continue;
232
+ rows.push({
233
+ fingerprint: `weather:${config.connectionId}:${candidate.type}:${time}`,
234
+ timestamp: time,
235
+ context: {
236
+ triggerEvent: {
237
+ provider: 'weather',
238
+ eventType: candidate.type,
239
+ location: forecast?.location?.label || config.location || null,
240
+ time,
241
+ rain,
242
+ snowfall,
243
+ windSpeed,
244
+ temperature,
245
+ },
246
+ },
247
+ });
248
+ }
249
+ }
250
+
251
+ return rows.sort(sortByTimestamp);
252
+ }
253
+
169
254
  return [];
170
255
  }
171
256
 
@@ -222,8 +222,8 @@ class TaskRuntime {
222
222
  const existing = this.scheduleJobs.get(taskId);
223
223
  if (existing) {
224
224
  existing.task.stop();
225
- this.scheduleJobs.delete(taskId);
226
225
  }
226
+ this.scheduleJobs.delete(taskId);
227
227
  }
228
228
 
229
229
  async _executeTask(taskId, userId, executionMeta = {}) {
@@ -23,6 +23,7 @@ class VoiceAgentBridge {
23
23
  session.currentRunId = runId;
24
24
 
25
25
  try {
26
+ const deferredFollowUp = await this.runtimeManager.prepareDeferredVoiceFollowUp(session);
26
27
  const result = await runVoiceTranscriptTurn({
27
28
  userId: session.userId,
28
29
  agentId: session.agentId,
@@ -41,10 +42,25 @@ class VoiceAgentBridge {
41
42
  runId,
42
43
  });
43
44
  session.currentRunId = result.runId || runId;
44
- if (String(result.replyText || '').trim()) {
45
- await this.runtimeManager.deliverAssistantMessage(session, result.replyText, {
46
- kind: 'final',
47
- });
45
+ const replyText = String(result.replyText || '').trim();
46
+ if (replyText) {
47
+ if (deferredFollowUp) {
48
+ const followUp = await this.runtimeManager.deliverDeferredVoiceFollowUp(
49
+ session,
50
+ deferredFollowUp,
51
+ replyText,
52
+ result.runId || runId,
53
+ );
54
+ if (!followUp?.sent) {
55
+ await this.runtimeManager.deliverAssistantMessage(session, replyText, {
56
+ kind: 'final',
57
+ });
58
+ }
59
+ } else {
60
+ await this.runtimeManager.deliverAssistantMessage(session, replyText, {
61
+ kind: 'final',
62
+ });
63
+ }
48
64
  }
49
65
  await session.setState('idle');
50
66
  session.currentRunId = null;
@@ -61,6 +61,9 @@ function buildDirectVoiceContext({
61
61
  allowInterimUpdates
62
62
  ? 'Do not use send_message. Use send_interim_update only for short spoken progress updates when silence would otherwise be noticeable.'
63
63
  : 'Do not use send_message or send_interim_update.',
64
+ allowInterimUpdates
65
+ ? 'You decide when a task is taking long. If so, send a natural deferral via send_interim_update and set defer_follow_up=true.'
66
+ : 'Keep the spoken response direct and concise.',
64
67
  'Return only the assistant reply.',
65
68
  ];
66
69
 
@@ -1,8 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const { randomUUID } = require('crypto');
4
+ const db = require('../../db/database');
4
5
 
5
6
  const { getProviderRuntimeConfig } = require('../ai/models');
7
+ const { isMainAgent, resolveAgentId } = require('../agents/manager');
6
8
  const { getVoiceRuntimeSettings } = require('./liveSettings');
7
9
  const { VoiceLiveSession } = require('./liveSession');
8
10
  const { OpenAiLiveRelayAdapter } = require('./openaiLiveRelayAdapter');
@@ -180,11 +182,14 @@ class VoiceRuntimeManager {
180
182
  await session.setState('idle');
181
183
  }
182
184
 
183
- async publishInterimUpdate({ sessionId, content, kind = 'progress' } = {}) {
185
+ async publishInterimUpdate({ sessionId, content, kind = 'progress', deferFollowUp = false } = {}) {
184
186
  const session = this.getSession(sessionId);
185
187
  if (!session || session.closed) {
186
188
  return { sent: false, skipped: true, reason: 'Voice session is not active.' };
187
189
  }
190
+ if (deferFollowUp === true) {
191
+ session.deferFollowUpRequested = true;
192
+ }
188
193
  await this.deliverAssistantMessage(session, content, { kind });
189
194
  return { sent: true };
190
195
  }
@@ -196,6 +201,88 @@ class VoiceRuntimeManager {
196
201
  await session.publishAssistantOutput(normalized, options);
197
202
  }
198
203
 
204
+ async prepareDeferredVoiceFollowUp(session) {
205
+ if (!session || session.closed) return null;
206
+ if (String(session.platform || '').trim() !== 'voice_live') return null;
207
+
208
+ const target = this.#resolvePreferredMessagingTarget(session.userId, session.agentId);
209
+ if (!target) return null;
210
+ return {
211
+ target,
212
+ };
213
+ }
214
+
215
+ async deliverDeferredVoiceFollowUp(session, followUpPlan, replyText, runId = null) {
216
+ if (!session || session.closed || !followUpPlan || session.deferFollowUpRequested !== true) {
217
+ return { sent: false, skipped: true };
218
+ }
219
+ const manager = this.#messagingManager();
220
+ if (!manager || typeof manager.sendMessage !== 'function') {
221
+ return { sent: false, skipped: true, reason: 'Messaging manager unavailable.' };
222
+ }
223
+
224
+ const target = followUpPlan.target;
225
+ if (!target?.platform || !target?.to) {
226
+ return { sent: false, skipped: true, reason: 'No deferred follow-up target.' };
227
+ }
228
+
229
+ const status = typeof manager.getPlatformStatus === 'function'
230
+ ? manager.getPlatformStatus(session.userId, target.platform, { agentId: session.agentId })
231
+ : null;
232
+ if (status && status.status !== 'connected') {
233
+ return { sent: false, skipped: true, reason: `Platform ${target.platform} is not connected.` };
234
+ }
235
+
236
+ const body = String(replyText || '').trim();
237
+ if (!body) {
238
+ return { sent: false, skipped: true, reason: 'Reply text is empty.' };
239
+ }
240
+
241
+ const followUpContent = [
242
+ 'Update from your voice request:',
243
+ '',
244
+ body,
245
+ ].join('\n');
246
+
247
+ let sendResult;
248
+ try {
249
+ sendResult = await manager.sendMessage(
250
+ session.userId,
251
+ target.platform,
252
+ target.to,
253
+ followUpContent,
254
+ {
255
+ agentId: session.agentId,
256
+ runId,
257
+ persistConversation: true,
258
+ },
259
+ );
260
+ } catch (err) {
261
+ console.error('Failed to send deferred voice follow-up:', err);
262
+ return {
263
+ sent: false,
264
+ skipped: false,
265
+ success: false,
266
+ error: err instanceof Error ? err.message : String(err),
267
+ runId,
268
+ };
269
+ }
270
+
271
+ session.deferFollowUpRequested = false;
272
+ const label = this.#platformLabel(target.platform);
273
+ await this.deliverAssistantMessage(
274
+ session,
275
+ `I sent the full result to your ${label} chat.`,
276
+ { kind: 'final' },
277
+ );
278
+
279
+ return {
280
+ sent: true,
281
+ target,
282
+ result: sendResult,
283
+ };
284
+ }
285
+
199
286
  async startTelnyxTurn({
200
287
  userId,
201
288
  agentId = null,
@@ -284,6 +371,54 @@ class VoiceRuntimeManager {
284
371
  }
285
372
  }
286
373
 
374
+ #messagingManager() {
375
+ return this.agentEngine?.messagingManager || this.agentEngine?.app?.locals?.messagingManager || null;
376
+ }
377
+
378
+ #readScopedSetting(userId, agentId, key) {
379
+ const row = db.prepare(
380
+ 'SELECT value FROM agent_settings WHERE user_id = ? AND agent_id = ? AND key = ?'
381
+ ).get(userId, agentId, key);
382
+ if (row) {
383
+ try {
384
+ return JSON.parse(row.value);
385
+ } catch {
386
+ return row.value;
387
+ }
388
+ }
389
+ if (!isMainAgent(userId, agentId)) return null;
390
+ const userRow = db.prepare(
391
+ 'SELECT value FROM user_settings WHERE user_id = ? AND key = ?'
392
+ ).get(userId, key);
393
+ if (!userRow) return null;
394
+ try {
395
+ return JSON.parse(userRow.value);
396
+ } catch {
397
+ return userRow.value;
398
+ }
399
+ }
400
+
401
+ #resolvePreferredMessagingTarget(userId, agentId = null) {
402
+ const manager = this.#messagingManager();
403
+ if (!manager) return null;
404
+ const scopedAgentId = resolveAgentId(userId, agentId);
405
+ const platform = String(this.#readScopedSetting(userId, scopedAgentId, 'last_platform') || '').trim();
406
+ const to = String(this.#readScopedSetting(userId, scopedAgentId, 'last_chat_id') || '').trim();
407
+ if (!platform || !to) return null;
408
+
409
+ const status = typeof manager.getPlatformStatus === 'function'
410
+ ? manager.getPlatformStatus(userId, platform, { agentId: scopedAgentId })
411
+ : null;
412
+ if (!status || status.status !== 'connected') return null;
413
+ return { platform, to };
414
+ }
415
+
416
+ #platformLabel(platformName) {
417
+ const raw = String(platformName || '').trim();
418
+ if (!raw) return 'message';
419
+ return raw.replace(/[_-]+/g, ' ');
420
+ }
421
+
287
422
  async #deliverFlutterAssistantOutput(socket, sessionId, session, content, options = {}) {
288
423
  const kind = String(options.kind || 'final').trim() || 'final';
289
424
  socket.emit('voice:assistant_text', {