neoagent 3.0.1-beta.7 → 3.0.1-beta.8

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 (38) hide show
  1. package/.env.example +19 -0
  2. package/README.md +1 -0
  3. package/docs/billing.md +203 -0
  4. package/docs/configuration.md +13 -0
  5. package/docs/index.md +1 -0
  6. package/lib/schema_migrations.js +84 -0
  7. package/package.json +2 -1
  8. package/server/admin/admin.js +2 -1
  9. package/server/admin/billing.js +292 -0
  10. package/server/admin/index.html +37 -0
  11. package/server/db/database.js +1 -0
  12. package/server/http/routes.js +9 -0
  13. package/server/middleware/requireBilling.js +12 -0
  14. package/server/public/.last_build_id +1 -1
  15. package/server/public/flutter_bootstrap.js +1 -1
  16. package/server/public/main.dart.js +4 -4
  17. package/server/routes/admin.js +110 -0
  18. package/server/routes/billing.js +122 -0
  19. package/server/routes/billing_webhook.js +43 -0
  20. package/server/routes/memory.js +1 -4
  21. package/server/routes/settings.js +1 -2
  22. package/server/routes/skills.js +1 -1
  23. package/server/services/ai/loop/agent_engine_core.js +1 -1
  24. package/server/services/ai/loop/completion_judge.js +2 -9
  25. package/server/services/ai/loop/conversation_loop.js +12 -26
  26. package/server/services/ai/loopPolicy.js +2 -39
  27. package/server/services/ai/models.js +18 -0
  28. package/server/services/ai/systemPrompt.js +17 -0
  29. package/server/services/ai/tools.js +1 -11
  30. package/server/services/billing/billing_email.js +106 -0
  31. package/server/services/billing/config.js +17 -0
  32. package/server/services/billing/plans.js +120 -0
  33. package/server/services/billing/stripe_client.js +21 -0
  34. package/server/services/billing/subscriptions.js +462 -0
  35. package/server/services/billing/trial_guard.js +111 -0
  36. package/server/services/manager.js +11 -0
  37. package/server/services/tasks/runtime.js +52 -12
  38. package/server/services/workspace/manager.js +23 -0
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const router = express.Router();
5
+ const db = require('../db/database');
6
+ const { requireAuth } = require('../middleware/auth');
7
+ const { requireBilling } = require('../middleware/requireBilling');
8
+ const { getStripeConfig } = require('../services/billing/config');
9
+ const { listPlans } = require('../services/billing/plans');
10
+ const subs = require('../services/billing/subscriptions');
11
+ const { getStripeClient } = require('../services/billing/stripe_client');
12
+
13
+ function isValidHttpsUrl(value) {
14
+ try {
15
+ const u = new URL(value);
16
+ return u.protocol === 'https:' || u.protocol === 'http:';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ router.use(requireBilling);
23
+
24
+ // Public — pricing page needs this without login.
25
+ router.get('/plans', (req, res) => {
26
+ try {
27
+ res.json({ plans: listPlans() });
28
+ } catch (err) {
29
+ res.status(500).json({ error: err.message });
30
+ }
31
+ });
32
+
33
+ // All remaining routes require authentication.
34
+ router.use(requireAuth);
35
+
36
+ router.get('/', (req, res) => {
37
+ try {
38
+ const userId = req.session.userId;
39
+ const subscription = subs.getActiveSubscription(userId);
40
+ const { publicKey } = getStripeConfig();
41
+ res.json({ subscription, stripePublishableKey: publicKey || null });
42
+ } catch (err) {
43
+ res.status(500).json({ error: err.message });
44
+ }
45
+ });
46
+
47
+ router.post('/checkout', async (req, res) => {
48
+ try {
49
+ const { planId, successUrl, cancelUrl } = req.body;
50
+ if (!planId || !successUrl || !cancelUrl) {
51
+ return res.status(400).json({ error: 'planId, successUrl and cancelUrl are required.' });
52
+ }
53
+ if (!isValidHttpsUrl(successUrl) || !isValidHttpsUrl(cancelUrl)) {
54
+ return res.status(400).json({ error: 'successUrl and cancelUrl must be valid URLs.' });
55
+ }
56
+ const url = await subs.createCheckoutSession(req.session.userId, planId, successUrl, cancelUrl);
57
+ res.json({ url });
58
+ } catch (err) {
59
+ res.status(err.statusCode || 500).json({ error: err.message });
60
+ }
61
+ });
62
+
63
+ router.post('/portal', async (req, res) => {
64
+ try {
65
+ const { returnUrl } = req.body;
66
+ if (!returnUrl) return res.status(400).json({ error: 'returnUrl is required.' });
67
+ if (!isValidHttpsUrl(returnUrl)) {
68
+ return res.status(400).json({ error: 'returnUrl must be a valid URL.' });
69
+ }
70
+ const url = await subs.createCustomerPortalSession(req.session.userId, returnUrl);
71
+ res.json({ url });
72
+ } catch (err) {
73
+ res.status(err.statusCode || 500).json({ error: err.message });
74
+ }
75
+ });
76
+
77
+ router.post('/trial', async (req, res) => {
78
+ try {
79
+ const { planId, deviceFingerprint } = req.body;
80
+ if (!planId) return res.status(400).json({ error: 'planId is required.' });
81
+ const ip = req.ip || req.socket?.remoteAddress;
82
+ const result = await subs.startTrial(req.session.userId, planId, { ip, deviceFp: deviceFingerprint });
83
+ res.json({ subscription: result.subscription });
84
+ } catch (err) {
85
+ res.status(err.statusCode || 500).json({ error: err.message });
86
+ }
87
+ });
88
+
89
+ router.post('/cancel', async (req, res) => {
90
+ try {
91
+ await subs.cancelSubscription(req.session.userId);
92
+ res.json({ ok: true });
93
+ } catch (err) {
94
+ res.status(err.statusCode || 500).json({ error: err.message });
95
+ }
96
+ });
97
+
98
+ router.get('/invoices', async (req, res) => {
99
+ try {
100
+ const stripe = getStripeClient();
101
+ const userId = req.session.userId;
102
+ const customer = db
103
+ .prepare('SELECT stripe_customer_id FROM billing_customers WHERE user_id = ?')
104
+ .get(userId);
105
+ if (!customer?.stripe_customer_id) return res.json({ invoices: [] });
106
+ const list = await stripe.invoices.list({ customer: customer.stripe_customer_id, limit: 12 });
107
+ const invoices = list.data.map((inv) => ({
108
+ id: inv.id,
109
+ amount_paid: inv.amount_paid,
110
+ currency: inv.currency,
111
+ status: inv.status,
112
+ created: inv.created,
113
+ hosted_invoice_url: inv.hosted_invoice_url,
114
+ invoice_pdf: inv.invoice_pdf,
115
+ }));
116
+ res.json({ invoices });
117
+ } catch (err) {
118
+ res.status(err.statusCode || 500).json({ error: err.message });
119
+ }
120
+ });
121
+
122
+ module.exports = router;
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const router = express.Router();
5
+ const { isBillingEnabled, getStripeConfig } = require('../services/billing/config');
6
+ const { getStripeClient } = require('../services/billing/stripe_client');
7
+ const { handleWebhookEvent } = require('../services/billing/subscriptions');
8
+
9
+ // Raw body required for Stripe signature verification — applied inline.
10
+ router.post('/', express.raw({ type: 'application/json' }), async (req, res) => {
11
+ if (!isBillingEnabled()) return res.status(404).json({ error: 'Billing is not enabled.' });
12
+
13
+ const sig = req.headers['stripe-signature'];
14
+ if (!sig) {
15
+ return res.status(400).json({ error: 'Missing stripe-signature header.' });
16
+ }
17
+
18
+ const { webhookSecret } = getStripeConfig();
19
+ if (!webhookSecret) {
20
+ console.warn('[billing] STRIPE_WEBHOOK_SECRET is not set; rejecting webhook.');
21
+ return res.status(500).json({ error: 'Webhook secret not configured.' });
22
+ }
23
+
24
+ let event;
25
+ try {
26
+ const stripe = getStripeClient();
27
+ event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
28
+ } catch (err) {
29
+ console.warn('[billing] Webhook signature verification failed:', err.message);
30
+ return res.status(400).json({ error: `Webhook error: ${err.message}` });
31
+ }
32
+
33
+ try {
34
+ await handleWebhookEvent(event);
35
+ } catch (err) {
36
+ console.error('[billing] Webhook handler error:', err);
37
+ // Still return 200 so Stripe does not retry immediately for handler bugs.
38
+ }
39
+
40
+ res.json({ received: true });
41
+ });
42
+
43
+ module.exports = router;
@@ -1,6 +1,7 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
3
  const rateLimit = require('express-rate-limit');
4
+ const db = require('../db/database');
4
5
  const { requireAuth } = require('../middleware/auth');
5
6
  const { sanitizeError } = require('../utils/security');
6
7
  const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
@@ -317,7 +318,6 @@ router.post('/memories', async (req, res) => {
317
318
  // Update a memory
318
319
  router.put('/memories/:id', async (req, res) => {
319
320
  const mm = req.app.locals.memoryManager;
320
- const db = require('../db/database');
321
321
  // Verify ownership before updating
322
322
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
323
323
  const existing = db.prepare('SELECT id FROM memories WHERE id = ? AND user_id = ? AND agent_id = ?').get(req.params.id, req.session.userId, agentId);
@@ -335,7 +335,6 @@ router.put('/memories/:id', async (req, res) => {
335
335
  // Delete a memory
336
336
  router.delete('/memories/:id', (req, res) => {
337
337
  const mm = req.app.locals.memoryManager;
338
- const db = require('../db/database');
339
338
  // Verify ownership before deleting
340
339
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
341
340
  const existing = db.prepare('SELECT id FROM memories WHERE id = ? AND user_id = ? AND agent_id = ?').get(req.params.id, req.session.userId, agentId);
@@ -346,7 +345,6 @@ router.delete('/memories/:id', (req, res) => {
346
345
 
347
346
  router.post('/memories/bulk-delete', (req, res) => {
348
347
  const mm = req.app.locals.memoryManager;
349
- const db = require('../db/database');
350
348
  const ids = normalizeMemoryIds(req.body?.ids);
351
349
  if (!ids.length) {
352
350
  return res.status(400).json({ error: 'ids is required' });
@@ -366,7 +364,6 @@ router.post('/memories/bulk-delete', (req, res) => {
366
364
 
367
365
  router.post('/memories/bulk-archive', (req, res) => {
368
366
  const mm = req.app.locals.memoryManager;
369
- const db = require('../db/database');
370
367
  const ids = normalizeMemoryIds(req.body?.ids);
371
368
  const archived = req.body?.archived !== false;
372
369
  if (!ids.length) {
@@ -34,6 +34,7 @@ const {
34
34
  } = require('../services/runtime/settings');
35
35
  const { isManagedDeployment } = require('../utils/deployment');
36
36
  const { getAgentIdFromRequest, isMainAgent, resolveAgentId } = require('../services/agents/manager');
37
+ const { getProviderHealthCatalog, getSupportedModels } = require('../services/ai/models');
37
38
 
38
39
  const AGENT_SETTING_KEYS = new Set([
39
40
  'cost_mode',
@@ -193,14 +194,12 @@ async function resetEnvBackedSettingValue(req, key) {
193
194
 
194
195
  // Get supported models metadata
195
196
  router.get('/meta/models', async (req, res) => {
196
- const { getSupportedModels } = require('../services/ai/models');
197
197
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
198
198
  const models = await getSupportedModels(req.session.userId, agentId);
199
199
  res.json({ models });
200
200
  });
201
201
 
202
202
  router.get('/meta/ai-providers', async (req, res) => {
203
- const { getProviderHealthCatalog, getSupportedModels } = require('../services/ai/models');
204
203
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
205
204
  const [providers, models] = await Promise.all([
206
205
  getProviderHealthCatalog(req.session.userId, agentId),
@@ -1,3 +1,4 @@
1
+ const fs = require('fs');
1
2
  const express = require('express');
2
3
  const router = express.Router();
3
4
  const { requireAuth } = require('../middleware/auth');
@@ -109,7 +110,6 @@ router.get('/:name', async (req, res) => {
109
110
  const runner = await getSkillRunner(req.app);
110
111
  const skill = runner.getSkill(req.params.name, req.session.userId);
111
112
  if (!skill) return res.status(404).json({ error: 'Skill not found' });
112
- const fs = require('fs');
113
113
  const content = fs.readFileSync(skill.filePath, 'utf-8');
114
114
  res.json({
115
115
  name: skill.name,
@@ -842,7 +842,7 @@ class AgentEngine {
842
842
  iteration,
843
843
  }),
844
844
  maxTokens: 200,
845
- normalize: (raw) => normalizeChurnAssessment(raw),
845
+ normalize: normalizeChurnAssessment,
846
846
  fallback: { assessment: 'churn', reason: 'churn assessment unavailable' },
847
847
  reasoningEffort: this.getReasoningEffort(providerName, options),
848
848
  telemetry: options,
@@ -241,15 +241,8 @@ function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
241
241
  };
242
242
  }
243
243
 
244
- /**
245
- * Ask the model to self-assess whether it is making genuine progress or
246
- * spinning in analysis-paralysis. This replaces the hardcoded nudge threshold
247
- * with an AI-controlled signal: the model knows its own plan better than any
248
- * regex over tool names can infer.
249
- *
250
- * Intentionally lightweight — the response is capped at 200 tokens and the
251
- * prompt is self-contained so the model can answer without re-reading history.
252
- */
244
+ // Intentionally lightweight (200-token cap, self-contained) so the model can
245
+ // answer cold without re-reading full conversation history.
253
246
  function buildChurnAssessmentPrompt({
254
247
  readOnlyCount,
255
248
  alreadyRead,
@@ -295,6 +295,13 @@ function buildNoProgressWrapupPrompt({ readOnlyCount = 0, alreadyRead = '', plat
295
295
  ].filter(Boolean).join('\n\n');
296
296
  }
297
297
 
298
+ function isDeliveryTerminated(runMeta, deliveryState) {
299
+ return runMeta?.noResponse === true
300
+ || deliveryState?.noResponse === true
301
+ || runMeta?.finalDeliverySent === true
302
+ || deliveryState?.finalDeliverySent === true;
303
+ }
304
+
298
305
  function cloneInterimHistory(history = []) {
299
306
  if (!Array.isArray(history)) return [];
300
307
  return history.map((item) => ({
@@ -1181,28 +1188,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1181
1188
  messages = steeringAtLoopStart.messages;
1182
1189
  messages = sanitizeConversationMessages(messages);
1183
1190
 
1184
- // Analysis-paralysis gate (EVE-style: AI self-assessment instead of
1185
- // hardcoded counters). The nudge threshold is derived from the goal
1186
- // contract complexity/autonomy_level that the model set during task
1187
- // analysis, making the loop budget sensitivity AI-controlled.
1188
- //
1189
- // Flow:
1190
- // readOnlyCount >= maxConsecutiveReadOnlyIterations → hard safety net,
1191
- // force wrap-up unconditionally (same as before).
1192
- // readOnlyCount >= churnNudgeThreshold → ask the model to self-assess:
1193
- // "progressing" → give grace (partial counter reset, loop continues).
1194
- // "churn" → inject the nudge so the model can course-correct.
1195
- // "blocked" → trigger the force wrap-up early (AI-authorised).
1191
+ // Analysis-paralysis gate: AI self-assesses at churnNudgeThreshold; hard
1192
+ // force-wrap-up fires at maxConsecutiveReadOnlyIterations unconditionally.
1196
1193
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1197
1194
  const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1198
1195
 
1199
- if (readOnlyCount > 0) {
1196
+ if (readOnlyCount >= 2) {
1200
1197
  const runGoalCtx = resolveRunGoalContext(engine.getRunMeta(runId), analysis, plan);
1201
1198
  const churnNudgeThreshold = resolveChurnNudgeThreshold(runGoalCtx.goalContract);
1202
1199
 
1203
1200
  let triggerForceWrapup = false;
1204
1201
  let forceWrapupSource = 'hard_limit';
1205
- // Compute alreadyRead lazily — only needed at or above the nudge threshold.
1206
1202
  let alreadyRead = '';
1207
1203
 
1208
1204
  if (readOnlyCount >= loopPolicy.maxConsecutiveReadOnlyIterations) {
@@ -2055,12 +2051,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2055
2051
  if (runMeta?.terminalInterim) {
2056
2052
  break;
2057
2053
  }
2058
- if (
2059
- runMeta?.noResponse === true
2060
- || options.deliveryState?.noResponse === true
2061
- || runMeta?.finalDeliverySent === true
2062
- || options.deliveryState?.finalDeliverySent === true
2063
- ) {
2054
+ if (isDeliveryTerminated(runMeta, options.deliveryState)) {
2064
2055
  break;
2065
2056
  }
2066
2057
  }
@@ -2079,12 +2070,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2079
2070
 
2080
2071
  if (engine.isRunStopped(runId)) break;
2081
2072
  if (engine.getRunMeta(runId)?.terminalInterim) break;
2082
- if (
2083
- engine.getRunMeta(runId)?.noResponse === true
2084
- || options.deliveryState?.noResponse === true
2085
- || engine.getRunMeta(runId)?.finalDeliverySent === true
2086
- || options.deliveryState?.finalDeliverySent === true
2087
- ) break;
2073
+ if (isDeliveryTerminated(engine.getRunMeta(runId), options.deliveryState)) break;
2088
2074
  if (engine.getRunMeta(runId)?.widgetSnapshotSaved) break;
2089
2075
  if (!engine.activeRuns.has(runId)) break;
2090
2076
  }
@@ -1,18 +1,5 @@
1
- /**
2
- * loopPolicy.js
3
- *
4
- * Single source of truth for every tunable limit in the agent loop.
5
- * No magic numbers live in engine.js — everything flows from here.
6
- *
7
- * Values resolve in priority order:
8
- * 1. Per-run option override (options.*)
9
- * 2. Agent AI settings (aiSettings.*)
10
- * 3. Hardcoded sane default
11
- *
12
- * "Open but stable": limits exist as safety nets, not as the primary
13
- * exit signal. The AI signals completion via task_complete; these
14
- * numbers only fire when something goes wrong.
15
- */
1
+ // Limits resolve in priority order: per-run options → agent AI settings → hardcoded defaults.
2
+ // They are safety nets only; task_complete / progress guards are the real exit signals.
16
3
 
17
4
  // The iteration ceiling is a pure runaway safety net, NOT the primary stop signal.
18
5
  // A run stops when it makes no real progress (consecutiveReadOnlyIterations cap,
@@ -38,24 +25,15 @@ const MAX_ALLOWED_TOOL_FAILURES = 50;
38
25
  const MAX_ALLOWED_MODEL_RECOVERIES = 10;
39
26
  const MAX_ALLOWED_BUDGET_CHARS = 500_000;
40
27
 
41
- /** Return n if finite and positive, otherwise fallback. */
42
28
  function finitePositive(n, fallback) {
43
29
  return Number.isFinite(n) && n > 0 ? n : fallback;
44
30
  }
45
31
 
46
- /** Clamp n to [lo, hi]; return fallback if not finite. */
47
32
  function clampFinite(n, lo, hi, fallback) {
48
33
  if (!Number.isFinite(n)) return fallback;
49
34
  return Math.min(Math.max(n, lo), hi);
50
35
  }
51
36
 
52
- /**
53
- * @param {object} aiSettings - from getAiSettings()
54
- * @param {string} triggerType - 'chat' | 'schedule' | 'subagent' | etc.
55
- * @param {string} analysisMode - 'direct_answer' | 'execute' | 'plan_execute'
56
- * @param {object} options - per-run options (may override anything)
57
- * @returns {LoopPolicy}
58
- */
59
37
  function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = 'execute', options = {}) {
60
38
  const autonomyPolicy = options.autonomyPolicy && typeof options.autonomyPolicy === 'object'
61
39
  ? options.autonomyPolicy
@@ -155,9 +133,6 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
155
133
  };
156
134
  }
157
135
 
158
- /**
159
- * Map a tool name to its result-size category.
160
- */
161
136
  function getToolCategory(toolName) {
162
137
  if (!toolName) return 'default';
163
138
  if (/^(read_file|write_file|search_files|list_directory|file_)/.test(toolName)) return 'file';
@@ -166,9 +141,6 @@ function getToolCategory(toolName) {
166
141
  return 'default';
167
142
  }
168
143
 
169
- /**
170
- * Resolve soft + hard limits for a specific tool from the policy.
171
- */
172
144
  function resolveToolResultLimits(toolName, policy) {
173
145
  const category = getToolCategory(toolName);
174
146
  const soft = policy.toolResultBudget[category] ?? policy.toolResultBudget.default;
@@ -176,15 +148,6 @@ function resolveToolResultLimits(toolName, policy) {
176
148
  return { softLimit: soft, hardLimit: hard };
177
149
  }
178
150
 
179
- /**
180
- * Resolve the read-only churn nudge threshold from the current goal contract.
181
- * The AI itself sets complexity and autonomy_level during task analysis, so
182
- * this threshold is indirectly AI-controlled rather than a hardcoded constant.
183
- *
184
- * Simple tasks get nudged sooner (2 read-only turns) because exploration is
185
- * rarely needed. Complex/high-autonomy tasks get more latitude (5 turns) before
186
- * the churn self-assessment fires.
187
- */
188
151
  function resolveChurnNudgeThreshold(goalContract) {
189
152
  const complexity = String(goalContract?.complexity || 'standard').toLowerCase();
190
153
  const autonomyLevel = String(goalContract?.autonomyLevel || goalContract?.autonomy_level || 'normal').toLowerCase();
@@ -433,6 +433,21 @@ async function getSupportedModels(userId, agentId = null) {
433
433
  const globalDisabledStr = process.env.NEOAGENT_DISABLED_MODELS || '';
434
434
  const globalDisabledSet = globalDisabledStr ? new Set(globalDisabledStr.split(',').map(s => s.trim()).filter(Boolean)) : null;
435
435
 
436
+ // Plan-based model allowlist (billing optional feature).
437
+ let planAllowedModels = null;
438
+ try {
439
+ const { isBillingEnabled } = require('../billing/config');
440
+ if (isBillingEnabled()) {
441
+ const { getActiveSubscription } = require('../billing/subscriptions');
442
+ const sub = getActiveSubscription(userId);
443
+ if (sub?.plan?.allowed_models?.length) {
444
+ planAllowedModels = new Set(sub.plan.allowed_models);
445
+ }
446
+ }
447
+ } catch {
448
+ // Billing restrictions are non-critical.
449
+ }
450
+
436
451
  return all.map((model) => {
437
452
  const provider = providerById.get(model.provider);
438
453
  // Ollama models are always local/free; all others look up the OpenRouter
@@ -445,6 +460,9 @@ async function getSupportedModels(userId, agentId = null) {
445
460
  if (available && globalDisabledSet?.has(model.id)) {
446
461
  available = false;
447
462
  }
463
+ if (available && planAllowedModels !== null && !planAllowedModels.has(model.id)) {
464
+ available = false;
465
+ }
448
466
 
449
467
  const bareId = model.id.includes('/') ? model.id.slice(model.id.indexOf('/') + 1) : null;
450
468
  const inputCostPerM = model.provider === 'ollama'
@@ -363,6 +363,23 @@ async function buildSystemPromptSections(userId, context = {}, memoryManager) {
363
363
  if (context.additionalContext) {
364
364
  dynamic.push(`Additional context:\n${clampSection(context.additionalContext, 1800)}`);
365
365
  }
366
+
367
+ // Inject subscription context when billing is enabled.
368
+ try {
369
+ const { isBillingEnabled } = require('../billing/config');
370
+ if (isBillingEnabled()) {
371
+ const { getActiveSubscription } = require('../billing/subscriptions');
372
+ const sub = getActiveSubscription(userId);
373
+ if (sub?.plan) {
374
+ const trialSuffix = sub.status === 'trialing' && sub.trial_ends_at
375
+ ? ` (trial ends ${sub.trial_ends_at.slice(0, 10)})`
376
+ : '';
377
+ dynamic.push(`SUBSCRIPTION: User is on the "${sub.plan.name}" plan, status: ${sub.status}${trialSuffix}.`);
378
+ }
379
+ }
380
+ } catch {
381
+ // Billing info is non-critical; never fail the prompt build.
382
+ }
366
383
  dynamic.push([
367
384
  'FINAL EXECUTION CONTRACT',
368
385
  'Follow the latest authenticated user request within the safety and trust rules above.',
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { analyzeImageForUser } = require('./imageAnalysis');
4
- const { isPrivateHost } = require('../../utils/cloud-security');
4
+ const { isPrivateHost, validateCloudUrl } = require('../../utils/cloud-security');
5
5
  const db = require('../../db/database');
6
6
  const { DATA_DIR } = require('../../../runtime/paths');
7
7
  const { isMainAgent } = require('../agents/manager');
@@ -1616,14 +1616,6 @@ function normalizeReadFileArgs(args = {}) {
1616
1616
  };
1617
1617
  }
1618
1618
 
1619
- /**
1620
- * Executes a tool by name.
1621
- * @param {string} toolName - Name of the tool.
1622
- * @param {object} args - Tool arguments.
1623
- * @param {object} context - Execution context (userId, runId, etc).
1624
- * @param {object} engine - AgentEngine instance.
1625
- * @returns {Promise<any>} Execution result.
1626
- */
1627
1619
  async function executeTool(toolName, args, context, engine) {
1628
1620
  const {
1629
1621
  userId,
@@ -1738,7 +1730,6 @@ async function executeTool(toolName, args, context, engine) {
1738
1730
  }
1739
1731
 
1740
1732
  case 'browser_navigate': {
1741
- const { validateCloudUrl } = require('../../utils/cloud-security');
1742
1733
  const urlCheck = validateCloudUrl(args.url);
1743
1734
  if (!urlCheck.allowed) return { error: 'URL is not allowed: blocked scheme or private/internal network address.' };
1744
1735
  const { provider, backend } = await bc();
@@ -3118,7 +3109,6 @@ async function executeTool(toolName, args, context, engine) {
3118
3109
 
3119
3110
  case 'ocr_extract': {
3120
3111
  try {
3121
- const fs = require('fs');
3122
3112
  if (!fs.existsSync(args.image_path)) {
3123
3113
  return { error: 'File not found: ' + args.image_path };
3124
3114
  }
@@ -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 };