neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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.
Files changed (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { analyzeImageForUser } = require('./imageAnalysis');
4
+ const { isPrivateHost, validateCloudUrl } = require('../../utils/cloud-security');
4
5
  const db = require('../../db/database');
5
6
  const { DATA_DIR } = require('../../../runtime/paths');
6
7
  const { isMainAgent } = require('../agents/manager');
@@ -96,12 +97,35 @@ function compactToolDefinition(tool, options = {}) {
96
97
  return compact;
97
98
  }
98
99
 
100
+ // A bare 5-field cron expression: "m h dom mon dow" (seconds unsupported).
101
+ const CRON_5_FIELD_RE = /^(\S+\s+){4}\S+$/;
102
+
103
+ function coerceScheduleTriggerConfig(inputConfig) {
104
+ // Models frequently pass the schedule config as a JSON string, or as a bare
105
+ // cron expression / ISO datetime instead of an object. Coerce those shapes to
106
+ // an object so a correct intent is not rejected over packaging.
107
+ if (typeof inputConfig !== 'string') return inputConfig;
108
+ const raw = inputConfig.trim();
109
+ if (!raw) return inputConfig;
110
+ if (raw.startsWith('{') || raw.startsWith('[')) {
111
+ try {
112
+ return JSON.parse(raw);
113
+ } catch {
114
+ return inputConfig;
115
+ }
116
+ }
117
+ if (CRON_5_FIELD_RE.test(raw)) return { mode: 'recurring', cronExpression: raw };
118
+ if (!Number.isNaN(Date.parse(raw))) return { mode: 'one_time', runAt: raw };
119
+ return inputConfig;
120
+ }
121
+
99
122
  function normalizeScheduleTriggerConfig(inputConfig = {}) {
100
- if (!inputConfig || typeof inputConfig !== 'object' || Array.isArray(inputConfig)) {
101
- return inputConfig;
123
+ const coerced = coerceScheduleTriggerConfig(inputConfig);
124
+ if (!coerced || typeof coerced !== 'object' || Array.isArray(coerced)) {
125
+ return coerced;
102
126
  }
103
127
 
104
- const normalized = { ...inputConfig };
128
+ const normalized = { ...coerced };
105
129
  const schedule = (normalized.schedule && typeof normalized.schedule === 'object' && !Array.isArray(normalized.schedule))
106
130
  ? normalized.schedule
107
131
  : null;
@@ -216,7 +240,7 @@ function validateProactiveSendMessageArgs({ purpose, normalizedMessage }) {
216
240
  }
217
241
 
218
242
  if (normalizedPurpose === 'no_response') {
219
- if (normalizedMessage !== '[NO RESPONSE]') {
243
+ if (normalizedMessage && normalizedMessage !== '[NO RESPONSE]') {
220
244
  return {
221
245
  ok: false,
222
246
  error: 'purpose=no_response requires content "[NO RESPONSE]".',
@@ -252,7 +276,33 @@ function getRunState(engine, runId) {
252
276
 
253
277
  function hasAlreadySentProactiveMessage({ triggerSource, runState, deliveryState, allowMultipleProactiveMessages }) {
254
278
  if (!isProactiveTrigger(triggerSource) || allowMultipleProactiveMessages) return false;
255
- return Boolean(runState?.messagingSent || deliveryState?.messagingSent);
279
+ return Boolean(
280
+ runState?.messagingSent
281
+ || deliveryState?.messagingSent
282
+ || runState?.proactiveMessageStaged
283
+ || deliveryState?.proactiveMessageStaged
284
+ );
285
+ }
286
+
287
+ function markProactiveMessageStaged({ runState, deliveryState, platform, to, content, purpose, mediaPath = null }) {
288
+ const staged = {
289
+ platform: String(platform || '').trim(),
290
+ to: String(to || '').trim(),
291
+ content: String(content || '').trim(),
292
+ purpose: String(purpose || '').trim().toLowerCase(),
293
+ mediaPath: mediaPath || null,
294
+ stagedAt: new Date().toISOString(),
295
+ };
296
+
297
+ if (runState) {
298
+ runState.proactiveMessageStaged = true;
299
+ runState.stagedProactiveMessage = staged;
300
+ }
301
+
302
+ if (deliveryState) {
303
+ deliveryState.proactiveMessageStaged = true;
304
+ deliveryState.stagedProactiveMessage = staged;
305
+ }
256
306
  }
257
307
 
258
308
  function markProactiveMessageSent({ runState, deliveryState, content }) {
@@ -866,7 +916,7 @@ function getAvailableTools(app, options = {}) {
866
916
  },
867
917
  {
868
918
  name: 'send_message',
869
- description: `Send a message on a connected messaging platform. Supports WhatsApp (text/media), Telnyx Voice (phone calls — TTS), Discord, Telegram, Slack, Google Chat, Microsoft Teams, Matrix, Signal, iMessage/BlueBubbles, IRC, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, WeChat, WebChat, and configurable webhook bridges. ${buildSendMessageFormattingReference()} For WhatsApp: use media_path to attach files. Use content "[NO RESPONSE]" only when the user explicitly asked for silence or no reply. For background task or schedule runs, set purpose to final_result, blocker, or no_response.`,
919
+ description: `Send a message on a connected messaging platform. Supports WhatsApp (text/media), Telnyx Voice (phone calls — TTS), Discord, Telegram, Slack, Google Chat, Microsoft Teams, Matrix, Signal, iMessage/BlueBubbles, IRC, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, WeChat, WebChat, and configurable webhook bridges. ${buildSendMessageFormattingReference()} For WhatsApp: use media_path to attach files. Use content "[NO RESPONSE]" only when the user explicitly asked for silence/no reply, or when a background task intentionally decides no user-visible update is needed with purpose="no_response". For background task or schedule runs, set purpose to final_result, blocker, or no_response.`,
870
920
  parameters: {
871
921
  type: 'object',
872
922
  properties: {
@@ -1452,6 +1502,30 @@ function getAvailableTools(app, options = {}) {
1452
1502
  required: ['query']
1453
1503
  }
1454
1504
  },
1505
+ {
1506
+ name: 'list_chats',
1507
+ description: 'List all known groups and conversations across connected messaging platforms. Use this when the user doesn\'t know the exact group name or chat ID — it returns every chat that has ever sent a message, with platform, chat ID, display name, and whether it\'s a group or DM.',
1508
+ parameters: {
1509
+ type: 'object',
1510
+ properties: {
1511
+ platform: { type: 'string', description: 'Filter to a specific platform (e.g. telegram, whatsapp, discord). Omit to list chats across all platforms.' },
1512
+ }
1513
+ }
1514
+ },
1515
+ {
1516
+ name: 'read_messages',
1517
+ description: 'Read recent messages from a connected messaging platform group or conversation. Use this to get recaps, search history, or summarize what\'s been going on in a Telegram group, WhatsApp chat, Discord channel, Slack channel, or any other connected platform. Messages are stored locally from inbound traffic.',
1518
+ parameters: {
1519
+ type: 'object',
1520
+ properties: {
1521
+ platform: { type: 'string', description: 'Platform name (e.g. telegram, whatsapp, discord, slack, signal, teams). Omit to search across all connected platforms.' },
1522
+ chat_id: { type: 'string', description: 'Exact platform chat or group ID. Use this when known.' },
1523
+ group_name: { type: 'string', description: 'Group, channel, or chat name to find (case-insensitive partial match). Use when you don\'t know the exact chat_id.' },
1524
+ limit: { type: 'number', description: 'Max messages to return (default 20, max 100).' },
1525
+ search: { type: 'string', description: 'Optional keyword to filter messages by content.' },
1526
+ }
1527
+ }
1528
+ },
1455
1529
  {
1456
1530
  name: 'social_video_extract',
1457
1531
  description: 'Extract title, description, transcript, and one representative frame image from a public social video URL (YouTube, TikTok, Instagram, or X) without social API keys.',
@@ -1503,11 +1577,11 @@ function getAvailableTools(app, options = {}) {
1503
1577
  0,
1504
1578
  {
1505
1579
  name: 'send_interim_update',
1506
- description: 'Send a short real interim assistant update when it helps.',
1580
+ description: 'Send a short user-visible interim update only when there is materially useful new progress, a real blocker, or a blocking question. Never use this for internal monologue, self-checks, tool bookkeeping, or "nothing happened" status.',
1507
1581
  parameters: {
1508
1582
  type: 'object',
1509
1583
  properties: {
1510
- content: { type: 'string', description: 'Natural assistant message derived from the current task state.' },
1584
+ content: { type: 'string', description: 'Natural assistant message derived from user-relevant task state, not internal reasoning or progress-supervisor bookkeeping.' },
1511
1585
  kind: { type: 'string', enum: Array.from(INTERIM_KINDS), description: 'ack, progress, question, or blocker' },
1512
1586
  expects_reply: { type: 'boolean', description: 'Set true only when the current run should pause for the user to answer.' },
1513
1587
  defer_follow_up: { type: 'boolean', description: 'Set true when you choose to deliver the final result later via the user\'s last connected chat target.' }
@@ -1592,14 +1666,6 @@ function normalizeReadFileArgs(args = {}) {
1592
1666
  };
1593
1667
  }
1594
1668
 
1595
- /**
1596
- * Executes a tool by name.
1597
- * @param {string} toolName - Name of the tool.
1598
- * @param {object} args - Tool arguments.
1599
- * @param {object} context - Execution context (userId, runId, etc).
1600
- * @param {object} engine - AgentEngine instance.
1601
- * @returns {Promise<any>} Execution result.
1602
- */
1603
1669
  async function executeTool(toolName, args, context, engine) {
1604
1670
  const {
1605
1671
  userId,
@@ -1714,6 +1780,8 @@ async function executeTool(toolName, args, context, engine) {
1714
1780
  }
1715
1781
 
1716
1782
  case 'browser_navigate': {
1783
+ const urlCheck = validateCloudUrl(args.url);
1784
+ if (!urlCheck.allowed) return { error: 'URL is not allowed: blocked scheme or private/internal network address.' };
1717
1785
  const { provider, backend } = await bc();
1718
1786
  if (!provider) return { error: 'Browser controller not available' };
1719
1787
  return { ...await provider.navigate(args.url, {
@@ -2227,6 +2295,91 @@ async function executeTool(toolName, args, context, engine) {
2227
2295
  };
2228
2296
  }
2229
2297
 
2298
+ case 'list_chats': {
2299
+ const listPlatform = typeof args.platform === 'string' ? args.platform.trim().toLowerCase() : null;
2300
+
2301
+ let listQuery = `SELECT platform, platform_chat_id, metadata
2302
+ FROM messages
2303
+ WHERE user_id = ? AND platform_chat_id IS NOT NULL AND platform != 'web'`;
2304
+ const listParams = [userId];
2305
+
2306
+ if (listPlatform) { listQuery += ' AND platform = ?'; listParams.push(listPlatform); }
2307
+ listQuery += ' ORDER BY id DESC LIMIT 2000';
2308
+
2309
+ const listRows = db.prepare(listQuery).all(...listParams);
2310
+
2311
+ const chatMap = new Map();
2312
+ for (const row of listRows) {
2313
+ const key = `${row.platform}:${row.platform_chat_id}`;
2314
+ if (chatMap.has(key)) continue;
2315
+ let meta = {};
2316
+ try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch {}
2317
+ const isGroup = String(meta.isGroup || '').match(/^(true|1)$/i) != null && String(meta.isGroup || '') !== '';
2318
+ const name = (meta.groupName || meta.group_name || meta.guildName || meta.guild_name || meta.chatName || meta.chat_name || '').trim()
2319
+ || (meta.senderName || meta.sender_name || '').trim()
2320
+ || null;
2321
+ chatMap.set(key, {
2322
+ platform: row.platform,
2323
+ chat_id: row.platform_chat_id,
2324
+ name,
2325
+ type: isGroup ? 'group' : 'dm',
2326
+ });
2327
+ }
2328
+
2329
+ const chats = Array.from(chatMap.values());
2330
+ return {
2331
+ count: chats.length,
2332
+ chats,
2333
+ };
2334
+ }
2335
+
2336
+ case 'read_messages': {
2337
+ const msgLimit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
2338
+ const msgPlatform = typeof args.platform === 'string' ? args.platform.trim().toLowerCase() : null;
2339
+ const msgChatId = typeof args.chat_id === 'string' ? args.chat_id.trim() : null;
2340
+ const msgGroupName = typeof args.group_name === 'string' ? args.group_name.trim().toLowerCase() : null;
2341
+ const msgSearch = typeof args.search === 'string' ? args.search.trim() : null;
2342
+
2343
+ let msgQuery = "SELECT role, content, platform, platform_chat_id, metadata, created_at FROM messages WHERE user_id = ? AND role = 'user'";
2344
+ const msgParams = [userId];
2345
+
2346
+ if (msgPlatform) { msgQuery += ' AND platform = ?'; msgParams.push(msgPlatform); }
2347
+ if (msgChatId) { msgQuery += ' AND platform_chat_id = ?'; msgParams.push(msgChatId); }
2348
+ if (msgSearch) { msgQuery += ' AND content LIKE ?'; msgParams.push(`%${msgSearch}%`); }
2349
+
2350
+ msgQuery += ' ORDER BY created_at DESC LIMIT ?';
2351
+ msgParams.push(msgGroupName ? Math.min(msgLimit * 10, 500) : msgLimit);
2352
+
2353
+ const msgRows = db.prepare(msgQuery).all(...msgParams);
2354
+
2355
+ let filteredRows = msgRows;
2356
+ if (msgGroupName) {
2357
+ filteredRows = msgRows.filter((row) => {
2358
+ try {
2359
+ const meta = row.metadata ? JSON.parse(row.metadata) : {};
2360
+ const name = (meta.groupName || meta.group_name || meta.guildName || meta.guild_name || meta.chatName || meta.chat_name || row.platform_chat_id || '').toLowerCase();
2361
+ return name.includes(msgGroupName);
2362
+ } catch { return false; }
2363
+ }).slice(0, msgLimit);
2364
+ }
2365
+
2366
+ return {
2367
+ count: filteredRows.length,
2368
+ messages: filteredRows.reverse().map((row) => {
2369
+ let meta = {};
2370
+ try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch {}
2371
+ return {
2372
+ platform: row.platform,
2373
+ chat_id: row.platform_chat_id,
2374
+ group_name: meta.groupName || meta.group_name || meta.guildName || meta.guild_name || null,
2375
+ sender: meta.senderName || meta.sender_name || null,
2376
+ content: row.content,
2377
+ timestamp: row.created_at,
2378
+ };
2379
+ }),
2380
+ };
2381
+ }
2382
+
2230
2383
  case 'social_video_extract': {
2231
2384
  const service = socialVideo();
2232
2385
  if (!service || typeof service.extractFromUrl !== 'function') {
@@ -2358,6 +2511,26 @@ async function executeTool(toolName, args, context, engine) {
2358
2511
  };
2359
2512
  }
2360
2513
 
2514
+ if (isProactiveTrigger(triggerSource) && context.stageProactiveMessages === true && !suppressReply) {
2515
+ markProactiveMessageStaged({
2516
+ runState,
2517
+ deliveryState,
2518
+ platform: args.platform,
2519
+ to: args.to,
2520
+ content: normalizedMessage,
2521
+ purpose: args.purpose,
2522
+ mediaPath: args.media_path,
2523
+ });
2524
+ return {
2525
+ success: true,
2526
+ staged: true,
2527
+ purpose: normalizeSendMessagePurpose(args.purpose),
2528
+ platform: args.platform,
2529
+ to: args.to,
2530
+ content: normalizedMessage,
2531
+ };
2532
+ }
2533
+
2361
2534
  const sendResult = await manager.sendMessage(userId, args.platform, args.to, args.content, {
2362
2535
  agentId,
2363
2536
  mediaPath: args.media_path,
@@ -2555,6 +2728,22 @@ async function executeTool(toolName, args, context, engine) {
2555
2728
  }
2556
2729
 
2557
2730
  case 'http_request': {
2731
+ let parsedUrl;
2732
+ try { parsedUrl = new URL(args.url); } catch {
2733
+ return { error: 'Invalid URL' };
2734
+ }
2735
+ const scheme = parsedUrl.protocol.replace(/:$/, '').toLowerCase();
2736
+ if (!['http', 'https'].includes(scheme)) {
2737
+ return { error: 'URL scheme not allowed. Only http and https are permitted.' };
2738
+ }
2739
+ const h = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '');
2740
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost')) {
2741
+ return { error: 'Loopback addresses are not permitted.' };
2742
+ }
2743
+ const allowPrivate = process.env.NEOAGENT_HTTP_ALLOW_PRIVATE !== 'false';
2744
+ if (!allowPrivate && isPrivateHost(parsedUrl.hostname)) {
2745
+ return { error: 'Private/internal network addresses are not permitted.' };
2746
+ }
2558
2747
  const controller = new AbortController();
2559
2748
  const timeoutMs = args.timeout_ms || 30000;
2560
2749
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -2571,11 +2760,28 @@ async function executeTool(toolName, args, context, engine) {
2571
2760
  }
2572
2761
  }
2573
2762
  const res = await fetch(args.url, options);
2574
- const text = await res.text();
2763
+ const MAX_BODY = 512 * 1024;
2764
+ const reader = res.body.getReader();
2765
+ const chunks = [];
2766
+ let total = 0;
2767
+ let truncated = false;
2768
+ while (true) {
2769
+ const { done, value } = await reader.read();
2770
+ if (done) break;
2771
+ total += value.length;
2772
+ if (total > MAX_BODY) {
2773
+ const take = MAX_BODY - (total - value.length);
2774
+ if (take > 0) chunks.push(value.slice(0, take));
2775
+ truncated = true;
2776
+ break;
2777
+ }
2778
+ chunks.push(value);
2779
+ }
2780
+ const text = Buffer.concat(chunks.map(c => Buffer.from(c))).toString('utf-8');
2575
2781
  return {
2576
2782
  status: res.status,
2577
2783
  headers: Object.fromEntries(res.headers.entries()),
2578
- body: text.length > 50000 ? text.slice(0, 50000) + '\n...[truncated]' : text
2784
+ body: truncated ? text + '\n...[truncated]' : text,
2579
2785
  };
2580
2786
  } catch (err) {
2581
2787
  if (err.name === 'AbortError') return { error: `Request timed out after ${timeoutMs} ms` };
@@ -3058,7 +3264,6 @@ async function executeTool(toolName, args, context, engine) {
3058
3264
 
3059
3265
  case 'ocr_extract': {
3060
3266
  try {
3061
- const fs = require('fs');
3062
3267
  if (!fs.existsSync(args.image_path)) {
3063
3268
  return { error: 'File not found: ' + args.image_path };
3064
3269
  }
@@ -3175,4 +3380,10 @@ async function executeTool(toolName, args, context, engine) {
3175
3380
  }
3176
3381
  }
3177
3382
 
3178
- module.exports = { getAvailableTools, executeTool, validateProactiveSendMessageArgs };
3383
+ module.exports = {
3384
+ getAvailableTools,
3385
+ executeTool,
3386
+ validateProactiveSendMessageArgs,
3387
+ resolveTaskTriggerArgs,
3388
+ normalizeScheduleTriggerConfig,
3389
+ };
@@ -582,7 +582,8 @@ class AndroidController {
582
582
  });
583
583
 
584
584
  progress('Waiting for Android to boot (can take 2–5 min on first run)…');
585
- await this.#waitForBoot();
585
+ // Abort early if the emulator process dies, instead of polling until timeout.
586
+ await this.#waitForBoot({ isAlive: () => this.#isPidAlive(proc.pid) });
586
587
 
587
588
  writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
588
589
  console.log(`[Android] Emulator ready on ${this.adbSerial}`);
@@ -593,10 +594,13 @@ class AndroidController {
593
594
  });
594
595
  }
595
596
 
596
- async #waitForBoot(timeoutMs = 10 * 60 * 1000) {
597
+ async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true } = {}) {
597
598
  const adb = adbBin(this.sdkDir);
598
599
  const deadline = Date.now() + timeoutMs;
599
600
  while (Date.now() < deadline) {
601
+ if (!isAlive()) {
602
+ throw new Error('Emulator process exited before Android finished booting (check virtualization/KVM support and the system image).');
603
+ }
600
604
  try {
601
605
  const r = spawnSync(adb, ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf8', timeout: 5000 });
602
606
  if (r.stdout?.trim() === '1') return;
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const { isBillingEnabled } = require('./config');
4
+
5
+ // Lazy-load to avoid circular dependencies at startup.
6
+ function sendServiceEmail() {
7
+ return require('../account/service_email').sendServiceEmail;
8
+ }
9
+
10
+ function isEmailAvailable() {
11
+ try {
12
+ return require('../account/service_email').isServiceEmailConfigured();
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function fmtDate(tsOrSeconds) {
19
+ if (!tsOrSeconds) return 'unknown';
20
+ const ms = typeof tsOrSeconds === 'number' && tsOrSeconds < 1e12
21
+ ? tsOrSeconds * 1000
22
+ : Number(tsOrSeconds);
23
+ return new Date(ms).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
24
+ }
25
+
26
+ async function send(user, message) {
27
+ if (!isBillingEnabled() || !isEmailAvailable()) return;
28
+ if (!user?.email) return;
29
+ try {
30
+ await sendServiceEmail()(user.email, message);
31
+ } catch {
32
+ // Best-effort; billing emails are non-critical.
33
+ }
34
+ }
35
+
36
+ async function sendTrialStarted(user, plan, trialEndTs) {
37
+ await send(user, {
38
+ subject: `Your ${plan?.name || 'trial'} trial has started`,
39
+ heading: 'Trial started',
40
+ body: `Your free trial of the ${plan?.name || 'plan'} plan is now active.`,
41
+ details: [{ label: 'Trial ends', value: fmtDate(trialEndTs) }],
42
+ });
43
+ }
44
+
45
+ async function sendTrialEnding(user, plan, trialEndTs) {
46
+ await send(user, {
47
+ subject: `Your ${plan?.name || ''} trial ends soon`,
48
+ heading: 'Trial ending soon',
49
+ body: `Your free trial of the ${plan?.name || 'plan'} plan will end on ${fmtDate(trialEndTs)}. Add a payment method to continue without interruption.`,
50
+ details: [{ label: 'Trial ends', value: fmtDate(trialEndTs) }],
51
+ });
52
+ }
53
+
54
+ async function sendSubscriptionStarted(user, plan) {
55
+ await send(user, {
56
+ subject: `Welcome to ${plan?.name || 'your subscription'}`,
57
+ heading: 'Subscription active',
58
+ body: `Your ${plan?.name || 'subscription'} plan is now active.`,
59
+ });
60
+ }
61
+
62
+ async function sendSubscriptionRenewed(user, plan) {
63
+ await send(user, {
64
+ subject: `Your ${plan?.name || 'subscription'} has been renewed`,
65
+ heading: 'Subscription renewed',
66
+ body: `Your ${plan?.name || 'subscription'} plan has been successfully renewed.`,
67
+ });
68
+ }
69
+
70
+ async function sendPaymentFailed(user, plan, nextRetryAt) {
71
+ const details = nextRetryAt
72
+ ? [{ label: 'Next retry', value: fmtDate(nextRetryAt) }]
73
+ : [];
74
+ await send(user, {
75
+ subject: 'Payment failed — action required',
76
+ heading: 'Payment failed',
77
+ body: `We were unable to charge your payment method for the ${plan?.name || 'subscription'} plan. Please update your billing details to avoid losing access.`,
78
+ details,
79
+ });
80
+ }
81
+
82
+ async function sendSubscriptionCanceled(user, plan) {
83
+ await send(user, {
84
+ subject: `Your ${plan?.name || 'subscription'} has been canceled`,
85
+ heading: 'Subscription canceled',
86
+ body: `Your ${plan?.name || 'subscription'} plan has been canceled. You may resubscribe at any time.`,
87
+ });
88
+ }
89
+
90
+ async function sendPlanChanged(user, _oldPlan, newPlan) {
91
+ await send(user, {
92
+ subject: `Your plan has changed to ${newPlan?.name || 'a new plan'}`,
93
+ heading: 'Plan updated',
94
+ body: `Your subscription has been updated to the ${newPlan?.name || 'new'} plan.`,
95
+ });
96
+ }
97
+
98
+ module.exports = {
99
+ sendTrialStarted,
100
+ sendTrialEnding,
101
+ sendSubscriptionStarted,
102
+ sendSubscriptionRenewed,
103
+ sendPaymentFailed,
104
+ sendSubscriptionCanceled,
105
+ sendPlanChanged,
106
+ };
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ function isBillingEnabled() {
4
+ const v = process.env.NEOAGENT_BILLING_ENABLED;
5
+ return v === '1' || v === 'true';
6
+ }
7
+
8
+ function getStripeConfig() {
9
+ return {
10
+ secretKey: process.env.STRIPE_SECRET_KEY,
11
+ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
12
+ publicKey: process.env.STRIPE_PUBLISHABLE_KEY,
13
+ trialDays: parseInt(process.env.BILLING_TRIAL_DAYS || '14', 10),
14
+ };
15
+ }
16
+
17
+ module.exports = { isBillingEnabled, getStripeConfig };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('crypto');
4
+ const db = require('../../db/database');
5
+
6
+ function listPlans({ includeInactive = false } = {}) {
7
+ const sql = includeInactive
8
+ ? 'SELECT * FROM billing_plans ORDER BY sort_order ASC, created_at ASC'
9
+ : 'SELECT * FROM billing_plans WHERE is_active = 1 ORDER BY sort_order ASC, created_at ASC';
10
+ return db.prepare(sql).all().map(parsePlan);
11
+ }
12
+
13
+ function getPlan(planId) {
14
+ const row = db.prepare('SELECT * FROM billing_plans WHERE id = ?').get(planId);
15
+ return row ? parsePlan(row) : null;
16
+ }
17
+
18
+ function getFreePlan() {
19
+ const row = db.prepare(
20
+ 'SELECT * FROM billing_plans WHERE price_cents = 0 AND is_active = 1 ORDER BY sort_order ASC LIMIT 1',
21
+ ).get();
22
+ return row ? parsePlan(row) : null;
23
+ }
24
+
25
+ function validatePlanData(data, requireName = true) {
26
+ if (requireName && !data.name?.trim()) {
27
+ throw Object.assign(new Error('Plan name is required.'), { statusCode: 400 });
28
+ }
29
+ if (data.price_cents !== undefined && (!Number.isInteger(data.price_cents) || data.price_cents < 0)) {
30
+ throw Object.assign(new Error('price_cents must be a non-negative integer.'), { statusCode: 400 });
31
+ }
32
+ if (data.currency !== undefined && !/^[a-z]{3}$/i.test(data.currency)) {
33
+ throw Object.assign(new Error('currency must be a 3-letter ISO code.'), { statusCode: 400 });
34
+ }
35
+ if (data.interval !== undefined && data.interval !== null && !['month', 'year'].includes(data.interval)) {
36
+ throw Object.assign(new Error('interval must be "month", "year", or null.'), { statusCode: 400 });
37
+ }
38
+ if (data.id !== undefined && !/^[a-zA-Z0-9_-]+$/.test(data.id)) {
39
+ throw Object.assign(new Error('Plan ID may only contain letters, numbers, underscores, and hyphens.'), { statusCode: 400 });
40
+ }
41
+ }
42
+
43
+ function createPlan(data) {
44
+ validatePlanData(data, true);
45
+ const id = data.id || `plan_${randomUUID().replace(/-/g, '')}`;
46
+ // UTC timestamp without timezone marker — SQLite stores it as TEXT in ISO-like format.
47
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
48
+ db.prepare(`
49
+ INSERT INTO billing_plans
50
+ (id, name, description, price_cents, currency, interval, stripe_price_id,
51
+ token_limit_4h, token_limit_weekly, allowed_models_json, features_json,
52
+ is_active, sort_order, created_at, updated_at)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
54
+ `).run(
55
+ id,
56
+ data.name,
57
+ data.description || '',
58
+ data.price_cents ?? 0,
59
+ data.currency || 'usd',
60
+ data.interval ?? 'month',
61
+ data.stripe_price_id ?? null,
62
+ data.token_limit_4h ?? null,
63
+ data.token_limit_weekly ?? null,
64
+ JSON.stringify(data.allowed_models ?? []),
65
+ JSON.stringify(data.features ?? []),
66
+ data.is_active !== false ? 1 : 0,
67
+ data.sort_order ?? 0,
68
+ now,
69
+ now,
70
+ );
71
+ return getPlan(id);
72
+ }
73
+
74
+ function updatePlan(planId, data) {
75
+ validatePlanData(data, false);
76
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
77
+ const fields = [];
78
+ const values = [];
79
+
80
+ if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
81
+ if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description); }
82
+ if (data.price_cents !== undefined) { fields.push('price_cents = ?'); values.push(data.price_cents); }
83
+ if (data.currency !== undefined) { fields.push('currency = ?'); values.push(data.currency); }
84
+ if (data.interval !== undefined) { fields.push('interval = ?'); values.push(data.interval); }
85
+ if (data.stripe_price_id !== undefined) { fields.push('stripe_price_id = ?'); values.push(data.stripe_price_id); }
86
+ if (data.token_limit_4h !== undefined) { fields.push('token_limit_4h = ?'); values.push(data.token_limit_4h); }
87
+ if (data.token_limit_weekly !== undefined) { fields.push('token_limit_weekly = ?'); values.push(data.token_limit_weekly); }
88
+ if (data.allowed_models !== undefined) { fields.push('allowed_models_json = ?'); values.push(JSON.stringify(data.allowed_models)); }
89
+ if (data.features !== undefined) { fields.push('features_json = ?'); values.push(JSON.stringify(data.features)); }
90
+ if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
91
+ if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
92
+
93
+ if (!fields.length) return getPlan(planId);
94
+
95
+ fields.push('updated_at = ?');
96
+ values.push(now);
97
+ values.push(planId);
98
+
99
+ db.prepare(`UPDATE billing_plans SET ${fields.join(', ')} WHERE id = ?`).run(...values);
100
+ return getPlan(planId);
101
+ }
102
+
103
+ function deletePlan(planId) {
104
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
105
+ db.prepare('UPDATE billing_plans SET is_active = 0, updated_at = ? WHERE id = ?').run(now, planId);
106
+ }
107
+
108
+ function parsePlan(row) {
109
+ return {
110
+ ...row,
111
+ allowed_models: tryParseJson(row.allowed_models_json, []),
112
+ features: tryParseJson(row.features_json, []),
113
+ is_active: Boolean(row.is_active),
114
+ };
115
+ }
116
+
117
+ function tryParseJson(str, fallback) {
118
+ try { return JSON.parse(str); } catch { return fallback; }
119
+ }
120
+
121
+ module.exports = { listPlans, getPlan, getFreePlan, createPlan, updatePlan, deletePlan };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const { isBillingEnabled, getStripeConfig } = require('./config');
4
+
5
+ let _client = null;
6
+
7
+ function getStripeClient() {
8
+ if (!isBillingEnabled()) {
9
+ throw new Error('Billing is not enabled.');
10
+ }
11
+ if (_client) return _client;
12
+ const { secretKey } = getStripeConfig();
13
+ if (!secretKey) {
14
+ throw new Error('STRIPE_SECRET_KEY is not configured.');
15
+ }
16
+ const Stripe = require('stripe');
17
+ _client = new Stripe(secretKey);
18
+ return _client;
19
+ }
20
+
21
+ module.exports = { getStripeClient };