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.
- package/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- 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
|
-
|
|
101
|
-
|
|
123
|
+
const coerced = coerceScheduleTriggerConfig(inputConfig);
|
|
124
|
+
if (!coerced || typeof coerced !== 'object' || Array.isArray(coerced)) {
|
|
125
|
+
return coerced;
|
|
102
126
|
}
|
|
103
127
|
|
|
104
|
-
const normalized = { ...
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 = {
|
|
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
|
-
|
|
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 };
|