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.
- package/.env.example +19 -0
- package/README.md +1 -0
- package/docs/billing.md +203 -0
- package/docs/configuration.md +13 -0
- package/docs/index.md +1 -0
- package/lib/schema_migrations.js +84 -0
- package/package.json +2 -1
- package/server/admin/admin.js +2 -1
- package/server/admin/billing.js +292 -0
- package/server/admin/index.html +37 -0
- package/server/db/database.js +1 -0
- package/server/http/routes.js +9 -0
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +110 -0
- package/server/routes/billing.js +122 -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/services/ai/loop/agent_engine_core.js +1 -1
- package/server/services/ai/loop/completion_judge.js +2 -9
- package/server/services/ai/loop/conversation_loop.js +12 -26
- package/server/services/ai/loopPolicy.js +2 -39
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/systemPrompt.js +17 -0
- package/server/services/ai/tools.js +1 -11
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +120 -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/manager.js +11 -0
- package/server/services/tasks/runtime.js +52 -12
- 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;
|
package/server/routes/memory.js
CHANGED
|
@@ -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),
|
package/server/routes/skills.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
1185
|
-
//
|
|
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
|
|
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
|
-
|
|
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 };
|