neoagent 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/manager.js +11 -1
- package/package.json +1 -1
- package/server/index.js +17 -268
- package/server/public/app.html +10 -0
- package/server/public/css/styles.css +64 -0
- package/server/public/js/app.js +53 -5
- package/server/routes/memory.js +1 -1
- package/server/routes/settings.js +78 -4
- package/server/routes/telnyx.js +35 -0
- package/server/services/ai/compaction.js +3 -2
- package/server/services/ai/engine.js +7 -1176
- package/server/services/ai/systemPrompt.js +113 -0
- package/server/services/ai/tools.js +1096 -0
- package/server/services/manager.js +172 -0
- package/server/services/messaging/base.js +12 -0
- package/server/services/messaging/discord.js +12 -18
- package/server/services/messaging/telegram.js +41 -47
- package/server/services/messaging/whatsapp.js +7 -8
- package/server/utils/logger.js +46 -0
- package/server/utils/whatsapp.js +46 -0
package/lib/manager.js
CHANGED
|
@@ -403,7 +403,17 @@ function cmdUpdate() {
|
|
|
403
403
|
logOk('Already up to date');
|
|
404
404
|
}
|
|
405
405
|
} else {
|
|
406
|
-
logWarn('No git repo detected;
|
|
406
|
+
logWarn('No git repo detected; attempting npm global update.');
|
|
407
|
+
if (commandExists('npm')) {
|
|
408
|
+
try {
|
|
409
|
+
runOrThrow('npm', ['update', '-g', 'neoagent']);
|
|
410
|
+
logOk('npm global update completed');
|
|
411
|
+
} catch {
|
|
412
|
+
logWarn('npm global update failed. Run: npm update -g neoagent');
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
logWarn('npm not found. Cannot perform global update.');
|
|
416
|
+
}
|
|
407
417
|
}
|
|
408
418
|
|
|
409
419
|
cmdRestart();
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -12,7 +12,10 @@ const fs = require('fs');
|
|
|
12
12
|
|
|
13
13
|
const db = require('./db/database');
|
|
14
14
|
const { requireAuth, requireNoAuth } = require('./middleware/auth');
|
|
15
|
-
const { sanitizeError
|
|
15
|
+
const { sanitizeError } = require('./utils/security');
|
|
16
|
+
const { setupConsoleInterceptor } = require('./utils/logger');
|
|
17
|
+
const { setupTelnyxWebhook } = require('./routes/telnyx');
|
|
18
|
+
const { startServices } = require('./services/manager');
|
|
16
19
|
|
|
17
20
|
const app = express();
|
|
18
21
|
const httpServer = createServer(app);
|
|
@@ -24,40 +27,7 @@ const io = new SocketIO(httpServer, {
|
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
// ── Console Log Interceptor ──
|
|
27
|
-
|
|
28
|
-
const MAX_LOG_HISTORY = 200;
|
|
29
|
-
|
|
30
|
-
function broadcastLog(type, args) {
|
|
31
|
-
const msg = Array.from(args).map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
|
32
|
-
const logEntry = { type, message: msg, timestamp: new Date().toISOString() };
|
|
33
|
-
logHistory.push(logEntry);
|
|
34
|
-
if (logHistory.length > MAX_LOG_HISTORY) logHistory.shift();
|
|
35
|
-
// Broadcast only to authenticated user rooms, never to unauthenticated sockets
|
|
36
|
-
for (const [, socket] of io.sockets.sockets) {
|
|
37
|
-
const uid = socket.request?.session?.userId;
|
|
38
|
-
if (uid) socket.emit('server:log', logEntry);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const originalConsole = {
|
|
43
|
-
log: console.log,
|
|
44
|
-
error: console.error,
|
|
45
|
-
warn: console.warn,
|
|
46
|
-
info: console.info
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
console.log = function (...args) { originalConsole.log.apply(console, args); broadcastLog('log', args); };
|
|
50
|
-
console.error = function (...args) { originalConsole.error.apply(console, args); broadcastLog('error', args); };
|
|
51
|
-
console.warn = function (...args) { originalConsole.warn.apply(console, args); broadcastLog('warn', args); };
|
|
52
|
-
console.info = function (...args) { originalConsole.info.apply(console, args); broadcastLog('info', args); };
|
|
53
|
-
|
|
54
|
-
io.on('connection', (socket) => {
|
|
55
|
-
socket.on('client:request_logs', () => {
|
|
56
|
-
// Only serve log history to authenticated sockets
|
|
57
|
-
if (!socket.request?.session?.userId) return;
|
|
58
|
-
socket.emit('server:log_history', logHistory);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
30
|
+
setupConsoleInterceptor(io);
|
|
61
31
|
|
|
62
32
|
if (!process.env.SESSION_SECRET) {
|
|
63
33
|
console.warn('WARNING: SESSION_SECRET not set — using insecure default. Set it in .env before exposing this server.');
|
|
@@ -69,22 +39,17 @@ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
69
39
|
|
|
70
40
|
// ── Middleware ──
|
|
71
41
|
|
|
72
|
-
// When running behind a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel…)
|
|
73
|
-
// set SECURE_COOKIES=true in .env so the session cookie is marked Secure and
|
|
74
|
-
// express trusts the X-Forwarded-* headers from the proxy.
|
|
75
42
|
const SECURE_COOKIES = process.env.SECURE_COOKIES === 'true';
|
|
76
43
|
if (SECURE_COOKIES) {
|
|
77
|
-
app.set('trust proxy', 1);
|
|
44
|
+
app.set('trust proxy', 1);
|
|
78
45
|
}
|
|
79
46
|
|
|
80
|
-
// WebSocket CSP source: ws+wss on plain HTTP, wss-only on HTTPS
|
|
81
47
|
const wsConnectSrc = SECURE_COOKIES ? ['wss:'] : ['ws:', 'wss:'];
|
|
82
48
|
|
|
83
49
|
app.use(helmet({
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
originAgentCluster: false, // OAC: causes "previously placed in site-keyed cluster" warning on HTTP
|
|
50
|
+
strictTransportSecurity: false,
|
|
51
|
+
crossOriginOpenerPolicy: false,
|
|
52
|
+
originAgentCluster: false,
|
|
88
53
|
contentSecurityPolicy: {
|
|
89
54
|
directives: {
|
|
90
55
|
defaultSrc: ["'self'"],
|
|
@@ -94,15 +59,13 @@ app.use(helmet({
|
|
|
94
59
|
imgSrc: ["'self'", "data:", "blob:", "https://api.qrserver.com"],
|
|
95
60
|
connectSrc: ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com", ...wsConnectSrc],
|
|
96
61
|
fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
|
|
97
|
-
formAction: ["'self'"],
|
|
98
|
-
frameAncestors: ["'self'"],
|
|
99
|
-
// Disable upgrade-insecure-requests — helmet adds this by default in v7+,
|
|
100
|
-
// which causes browsers to upgrade HTTP subresource requests to HTTPS,
|
|
101
|
-
// breaking plain-HTTP deployments (Tailscale, localhost).
|
|
62
|
+
formAction: ["'self'"],
|
|
63
|
+
frameAncestors: ["'self'"],
|
|
102
64
|
upgradeInsecureRequests: null
|
|
103
65
|
}
|
|
104
66
|
}
|
|
105
67
|
}));
|
|
68
|
+
|
|
106
69
|
app.use(cors({
|
|
107
70
|
origin: process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : false,
|
|
108
71
|
credentials: true
|
|
@@ -120,13 +83,12 @@ const sessionMiddleware = session({
|
|
|
120
83
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
121
84
|
httpOnly: true,
|
|
122
85
|
sameSite: 'lax',
|
|
123
|
-
secure: SECURE_COOKIES
|
|
86
|
+
secure: SECURE_COOKIES
|
|
124
87
|
}
|
|
125
88
|
});
|
|
126
89
|
|
|
127
90
|
app.use(sessionMiddleware);
|
|
128
91
|
|
|
129
|
-
// Share session with Socket.IO
|
|
130
92
|
io.use((socket, next) => {
|
|
131
93
|
sessionMiddleware(socket.request, {}, next);
|
|
132
94
|
});
|
|
@@ -146,32 +108,8 @@ app.use('/api/scheduler', require('./routes/scheduler'));
|
|
|
146
108
|
app.use('/api/browser', require('./routes/browser'));
|
|
147
109
|
|
|
148
110
|
// ── Telnyx voice webhook ──
|
|
149
|
-
|
|
150
|
-
// Set TELNYX_WEBHOOK_TOKEN in .env and append ?token=<value> to the webhook URL
|
|
151
|
-
// you configure in the Telnyx portal / NeoAgent connect modal.
|
|
152
|
-
app.post('/api/telnyx/webhook', (req, res, next) => {
|
|
153
|
-
const expected = process.env.TELNYX_WEBHOOK_TOKEN;
|
|
154
|
-
if (expected) {
|
|
155
|
-
const provided = req.query.token || '';
|
|
156
|
-
// Use timing-safe comparison to prevent token brute-forcing via timing oracles
|
|
157
|
-
const crypto = require('crypto');
|
|
158
|
-
const a = Buffer.from(provided.padEnd(expected.length));
|
|
159
|
-
const b = Buffer.from(expected);
|
|
160
|
-
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
161
|
-
console.warn('[Telnyx webhook] Rejected request with invalid or missing token');
|
|
162
|
-
return res.status(403).send('Forbidden');
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
next();
|
|
166
|
-
}, async (req, res) => {
|
|
167
|
-
res.status(200).send('OK'); // Acknowledge immediately
|
|
168
|
-
const manager = app.locals.messagingManager;
|
|
169
|
-
if (manager) await manager.handleTelnyxWebhook(req.body).catch(err => console.error('[Telnyx webhook]', err.message));
|
|
170
|
-
});
|
|
111
|
+
setupTelnyxWebhook(app);
|
|
171
112
|
|
|
172
|
-
// Telnyx-generated audio files must be publicly accessible so Telnyx's servers
|
|
173
|
-
// can fetch them via the webhook callback URL. Directory listing is disabled and
|
|
174
|
-
// non-audio files are rejected to minimize accidental exposure.
|
|
175
113
|
app.use('/telnyx-audio', express.static(path.join(DATA_DIR, 'telnyx-audio'), {
|
|
176
114
|
index: false,
|
|
177
115
|
setHeaders: (res, filePath) => {
|
|
@@ -197,8 +135,6 @@ app.get('/app/*', requireAuth, (req, res) => {
|
|
|
197
135
|
res.sendFile(path.join(__dirname, 'public', 'app.html'));
|
|
198
136
|
});
|
|
199
137
|
|
|
200
|
-
// Serve app.html and app.js explicitly behind auth so they can't be fetched
|
|
201
|
-
// directly via the static middleware without a valid session.
|
|
202
138
|
app.get('/app.html', requireAuth, (req, res) => {
|
|
203
139
|
res.sendFile(path.join(__dirname, 'public', 'app.html'));
|
|
204
140
|
});
|
|
@@ -218,198 +154,11 @@ app.get('/api/health', requireAuth, (req, res) => {
|
|
|
218
154
|
});
|
|
219
155
|
|
|
220
156
|
// ── Service Initialization ──
|
|
221
|
-
|
|
222
|
-
const startServices = async () => {
|
|
223
|
-
try {
|
|
224
|
-
const { MemoryManager } = require('./services/memory/manager');
|
|
225
|
-
const memoryManager = new MemoryManager();
|
|
226
|
-
app.locals.memoryManager = memoryManager;
|
|
227
|
-
|
|
228
|
-
const { MCPClient } = require('./services/mcp/client');
|
|
229
|
-
const mcpClient = new MCPClient();
|
|
230
|
-
app.locals.mcpClient = mcpClient;
|
|
231
|
-
|
|
232
|
-
const { BrowserController } = require('./services/browser/controller');
|
|
233
|
-
const browserController = new BrowserController();
|
|
234
|
-
// Restore saved headless preference for the first user (single-user app)
|
|
235
|
-
const headlessSetting = db.prepare('SELECT value FROM user_settings WHERE key = ? ORDER BY user_id LIMIT 1').get('headless_browser');
|
|
236
|
-
if (headlessSetting) {
|
|
237
|
-
const val = headlessSetting.value;
|
|
238
|
-
browserController.headless = val !== 'false' && val !== false && val !== '0';
|
|
239
|
-
}
|
|
240
|
-
app.locals.browserController = browserController;
|
|
241
|
-
|
|
242
|
-
const { AgentEngine } = require('./services/ai/engine');
|
|
243
|
-
const agentEngine = new AgentEngine(io, { memoryManager, mcpClient, browserController, messagingManager: null /* set below */ });
|
|
244
|
-
app.locals.agentEngine = agentEngine;
|
|
245
|
-
|
|
246
|
-
const { MultiStepOrchestrator } = require('./services/ai/multiStep');
|
|
247
|
-
const multiStep = new MultiStepOrchestrator(agentEngine, io);
|
|
248
|
-
app.locals.multiStep = multiStep;
|
|
249
|
-
|
|
250
|
-
const { MessagingManager } = require('./services/messaging/manager');
|
|
251
|
-
const messagingManager = new MessagingManager(io);
|
|
252
|
-
app.locals.messagingManager = messagingManager;
|
|
253
|
-
// Inject messagingManager into the already-created agentEngine
|
|
254
|
-
agentEngine.messagingManager = messagingManager;
|
|
255
|
-
|
|
256
|
-
messagingManager.restoreConnections().catch(err => console.error('[Messaging] Restore error:', err.message));
|
|
257
|
-
|
|
258
|
-
const users = db.prepare('SELECT id FROM users').all();
|
|
259
|
-
for (const u of users) {
|
|
260
|
-
mcpClient.loadFromDB(u.id).catch(err => console.error('[MCP] Auto-start error:', err.message));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Per-user message queues: batch & combine messages while AI is busy
|
|
264
|
-
const userQueues = {};
|
|
265
|
-
app.locals.userQueues = userQueues;
|
|
266
|
-
|
|
267
|
-
async function processMessage(userId, msg) {
|
|
268
|
-
if (!userQueues[userId]) userQueues[userId] = { running: false, pending: [] };
|
|
269
|
-
const q = userQueues[userId];
|
|
270
|
-
|
|
271
|
-
if (q.running) {
|
|
272
|
-
const last = q.pending[q.pending.length - 1];
|
|
273
|
-
if (last && last.platform === msg.platform && last.chatId === msg.chatId) {
|
|
274
|
-
last.content += '\n' + msg.content;
|
|
275
|
-
last.messageId = msg.messageId;
|
|
276
|
-
} else {
|
|
277
|
-
q.pending.push({ ...msg });
|
|
278
|
-
}
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
q.running = true;
|
|
283
|
-
try {
|
|
284
|
-
await messagingManager.markRead(userId, msg.platform, msg.chatId, msg.messageId).catch(() => { });
|
|
285
|
-
await messagingManager.sendTyping(userId, msg.platform, msg.chatId, true).catch(() => { });
|
|
286
|
-
const mediaNote = msg.localMediaPath
|
|
287
|
-
? `\nMedia attached at: ${msg.localMediaPath} (type: ${msg.mediaType}). You can reference or forward it with send_message media_path.`
|
|
288
|
-
: '';
|
|
289
|
-
// Detect and log prompt injection attempts from external sources
|
|
290
|
-
if (detectPromptInjection(msg.content)) {
|
|
291
|
-
console.warn(`[Security] Possible prompt injection attempt from ${msg.sender} on ${msg.platform}: ${msg.content.slice(0, 200)}`);
|
|
292
|
-
}
|
|
293
|
-
// Wrap external content in delimiters — prevents prompt injection from untrusted senders
|
|
294
|
-
const isVoiceCall = msg.platform === 'telnyx' && msg.mediaType === 'voice';
|
|
295
|
-
const isVoiceNote = !isVoiceCall && msg.mediaType === 'audio'; // e.g. WhatsApp voice notes transcribed via STT
|
|
296
|
-
const isDiscordGuild = msg.platform === 'discord' && msg.isGroup;
|
|
297
|
-
|
|
298
|
-
// Channel context block for Discord guild/channel messages
|
|
299
|
-
const discordContext = (isDiscordGuild && Array.isArray(msg.channelContext) && msg.channelContext.length)
|
|
300
|
-
? '\n\nRecent channel context (oldest → newest):\n' +
|
|
301
|
-
msg.channelContext.map(m => `[${m.author}]: ${m.content}`).join('\n')
|
|
302
|
-
: '';
|
|
303
|
-
|
|
304
|
-
const sttNote = isVoiceNote
|
|
305
|
-
? '\n[Note: This message was sent as a voice note and transcribed via speech-to-text. The transcription may not be perfectly accurate — words may be misheard, punctuation added automatically, and phrasing may differ from what was intended.]'
|
|
306
|
-
: '';
|
|
307
|
-
|
|
308
|
-
const prompt = isVoiceCall
|
|
309
|
-
? `You are on a live phone call. The caller (${msg.senderName || msg.sender}) said (transcribed via speech-to-text — may not be perfectly accurate):
|
|
310
|
-
<caller_speech>
|
|
311
|
-
${msg.content}
|
|
312
|
-
</caller_speech>
|
|
313
|
-
|
|
314
|
-
Respond via send_message with platform="telnyx" and to="${msg.chatId}".
|
|
315
|
-
Rules for voice responses:
|
|
316
|
-
- Call send_message EXACTLY ONCE with your complete reply. Do NOT call it multiple times.
|
|
317
|
-
- Keep it brief and conversational — this will be spoken aloud via TTS.
|
|
318
|
-
- NO markdown, bullet points, bold, headers, or special formatting.
|
|
319
|
-
- Speak naturally. Never say things like "How can I assist you further?" — just stop when done.
|
|
320
|
-
- Always respond; never use [NO RESPONSE] on a live call.`
|
|
321
|
-
: `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}
|
|
322
|
-
|
|
323
|
-
Reply to this message using send_message with platform="${msg.platform}" and to="${msg.chatId}".
|
|
324
|
-
Text like a person: split across messages naturally when it fits the content. Never end with "anything else?" or close-out phrases — just stop when you're done.
|
|
325
|
-
You can also send images/files by setting media_path to a local file path.
|
|
326
|
-
If no reply is needed (e.g. the message is just an acknowledgement like "ok", "thanks", or you already said everything), call send_message with content "[NO RESPONSE]" to explicitly stay silent.`;
|
|
327
|
-
// Get or create a persistent conversation keyed by platform + chat ID.
|
|
328
|
-
// The engine's conversationId path handles history, time-gap markers, and compaction natively.
|
|
329
|
-
let convRow = db.prepare(
|
|
330
|
-
'SELECT id FROM conversations WHERE user_id = ? AND platform = ? AND platform_chat_id = ?'
|
|
331
|
-
).get(userId, msg.platform, msg.chatId);
|
|
332
|
-
if (!convRow) {
|
|
333
|
-
const convId = require('crypto').randomUUID();
|
|
334
|
-
db.prepare(
|
|
335
|
-
'INSERT INTO conversations (id, user_id, platform, platform_chat_id, title) VALUES (?, ?, ?, ?, ?)'
|
|
336
|
-
).run(convId, userId, msg.platform, msg.chatId, `${msg.platform} — ${msg.senderName || msg.sender || msg.chatId}`);
|
|
337
|
-
convRow = { id: convId };
|
|
338
|
-
}
|
|
339
|
-
const runOpts = { triggerSource: 'messaging', conversationId: convRow.id, source: msg.platform, chatId: msg.chatId, context: { rawUserMessage: msg.content } };
|
|
340
|
-
if (msg.localMediaPath) runOpts.mediaAttachments = [{ path: msg.localMediaPath, type: msg.mediaType }];
|
|
341
|
-
await agentEngine.run(userId, prompt, runOpts);
|
|
342
|
-
} finally {
|
|
343
|
-
await messagingManager.sendTyping(userId, msg.platform, msg.chatId, false).catch(() => { });
|
|
344
|
-
q.running = false;
|
|
345
|
-
if (q.pending.length > 0) {
|
|
346
|
-
const next = q.pending.shift();
|
|
347
|
-
processMessage(userId, next);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Wire messaging → agent: incoming messages trigger agent
|
|
353
|
-
messagingManager.registerHandler(async (userId, msg) => {
|
|
354
|
-
// Discord and Telegram handle their own access control internally
|
|
355
|
-
if (msg.platform !== 'discord' && msg.platform !== 'telegram') {
|
|
356
|
-
// Whitelist check: if user has set a whitelist for this platform, block unknown senders
|
|
357
|
-
const whitelistRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
358
|
-
.get(userId, `platform_whitelist_${msg.platform}`);
|
|
359
|
-
if (whitelistRow) {
|
|
360
|
-
try {
|
|
361
|
-
const whitelist = JSON.parse(whitelistRow.value);
|
|
362
|
-
if (Array.isArray(whitelist) && whitelist.length > 0) {
|
|
363
|
-
const normalize = (id) => {
|
|
364
|
-
const digits = (id || '').replace(/[^0-9]/g, '');
|
|
365
|
-
return digits.length > 10 ? digits.slice(-10) : digits;
|
|
366
|
-
};
|
|
367
|
-
const senderNorm = normalize(msg.sender || msg.chatId);
|
|
368
|
-
const allowed = whitelist.some(n => normalize(n) === senderNorm);
|
|
369
|
-
if (!allowed) {
|
|
370
|
-
console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
|
|
371
|
-
io.to(`user:${userId}`).emit('messaging:blocked_sender', {
|
|
372
|
-
platform: msg.platform,
|
|
373
|
-
sender: msg.sender,
|
|
374
|
-
chatId: msg.chatId,
|
|
375
|
-
senderName: msg.senderName || null
|
|
376
|
-
});
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
} catch { /* malformed whitelist, allow through */ }
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const upsertSetting = db.prepare('INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
|
385
|
-
upsertSetting.run(userId, 'last_platform', msg.platform);
|
|
386
|
-
upsertSetting.run(userId, 'last_chat_id', msg.chatId);
|
|
387
|
-
|
|
388
|
-
await processMessage(userId, msg);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
const { Scheduler } = require('./services/scheduler/cron');
|
|
392
|
-
const scheduler = new Scheduler(io, agentEngine);
|
|
393
|
-
app.locals.scheduler = scheduler;
|
|
394
|
-
agentEngine.scheduler = scheduler;
|
|
395
|
-
scheduler.start();
|
|
396
|
-
|
|
397
|
-
const { setupWebSocket } = require('./services/websocket');
|
|
398
|
-
setupWebSocket(io, { agentEngine, messagingManager, mcpClient, scheduler, memoryManager, app });
|
|
399
|
-
|
|
400
|
-
app.locals.io = io;
|
|
401
|
-
|
|
402
|
-
console.log('All services initialized');
|
|
403
|
-
} catch (err) {
|
|
404
|
-
console.error('Service init error:', err);
|
|
405
|
-
}
|
|
406
|
-
};
|
|
157
|
+
// Handled by services/manager.js
|
|
407
158
|
|
|
408
159
|
// ── Global Error Handler ──
|
|
409
160
|
|
|
410
|
-
|
|
411
|
-
// and stack traces are never exposed in API responses.
|
|
412
|
-
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
|
161
|
+
app.use((err, req, res, next) => {
|
|
413
162
|
console.error('[Unhandled error]', err);
|
|
414
163
|
const status = err.status || err.statusCode || 500;
|
|
415
164
|
const message = sanitizeError(err);
|
|
@@ -421,7 +170,7 @@ app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
|
|
421
170
|
|
|
422
171
|
httpServer.listen(PORT, async () => {
|
|
423
172
|
console.log(`NeoAgent running on http://localhost:${PORT}`);
|
|
424
|
-
await startServices();
|
|
173
|
+
await startServices(app, io);
|
|
425
174
|
});
|
|
426
175
|
|
|
427
176
|
// ── Graceful Shutdown ──
|
package/server/public/app.html
CHANGED
|
@@ -537,6 +537,16 @@
|
|
|
537
537
|
<!-- Rendered dynamically by app.js -->
|
|
538
538
|
</div>
|
|
539
539
|
</div>
|
|
540
|
+
<div class="form-group">
|
|
541
|
+
<label class="form-label settings-inline-label">
|
|
542
|
+
<span>Token Usage</span>
|
|
543
|
+
<span class="settings-info-wrap" tabindex="0" aria-label="Token usage info">
|
|
544
|
+
<span class="settings-info-icon">i</span>
|
|
545
|
+
<span class="settings-info-pop">Run-level totals from the DB. Used to track token usage trends in this app.</span>
|
|
546
|
+
</span>
|
|
547
|
+
</label>
|
|
548
|
+
<div class="settings-token-box" id="tokenUsageSummary">Loading token usage…</div>
|
|
549
|
+
</div>
|
|
540
550
|
<div class="settings-update-panel" id="settingsUpdatePanel">
|
|
541
551
|
<div class="settings-update-head">
|
|
542
552
|
<div class="settings-update-title">App Update</div>
|
|
@@ -191,6 +191,70 @@ button, input, textarea, select {
|
|
|
191
191
|
color: var(--text-secondary);
|
|
192
192
|
text-transform: uppercase;
|
|
193
193
|
}
|
|
194
|
+
.settings-inline-label {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: 8px;
|
|
198
|
+
}
|
|
199
|
+
.settings-info-wrap {
|
|
200
|
+
position: relative;
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
width: 15px;
|
|
205
|
+
height: 15px;
|
|
206
|
+
border-radius: 999px;
|
|
207
|
+
border: 1px solid var(--border-light);
|
|
208
|
+
color: var(--text-secondary);
|
|
209
|
+
font-size: 10px;
|
|
210
|
+
line-height: 1;
|
|
211
|
+
cursor: help;
|
|
212
|
+
text-transform: none;
|
|
213
|
+
}
|
|
214
|
+
.settings-info-icon {
|
|
215
|
+
display: inline-flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
justify-content: center;
|
|
218
|
+
width: 100%;
|
|
219
|
+
height: 100%;
|
|
220
|
+
}
|
|
221
|
+
.settings-info-pop {
|
|
222
|
+
position: absolute;
|
|
223
|
+
top: 20px;
|
|
224
|
+
left: 50%;
|
|
225
|
+
transform: translateX(-50%);
|
|
226
|
+
min-width: 240px;
|
|
227
|
+
max-width: 280px;
|
|
228
|
+
padding: 8px 10px;
|
|
229
|
+
border-radius: 8px;
|
|
230
|
+
border: 1px solid var(--border-light);
|
|
231
|
+
background: rgba(14,14,26,.98);
|
|
232
|
+
color: var(--text-primary);
|
|
233
|
+
font-size: 11px;
|
|
234
|
+
font-weight: 400;
|
|
235
|
+
letter-spacing: 0;
|
|
236
|
+
text-transform: none;
|
|
237
|
+
line-height: 1.4;
|
|
238
|
+
white-space: normal;
|
|
239
|
+
opacity: 0;
|
|
240
|
+
pointer-events: none;
|
|
241
|
+
transition: opacity 120ms ease;
|
|
242
|
+
z-index: 2;
|
|
243
|
+
}
|
|
244
|
+
.settings-info-wrap:hover .settings-info-pop,
|
|
245
|
+
.settings-info-wrap:focus .settings-info-pop,
|
|
246
|
+
.settings-info-wrap:focus-within .settings-info-pop {
|
|
247
|
+
opacity: 1;
|
|
248
|
+
}
|
|
249
|
+
.settings-token-box {
|
|
250
|
+
border: 1px solid var(--border);
|
|
251
|
+
border-radius: 10px;
|
|
252
|
+
padding: 10px 12px;
|
|
253
|
+
background: rgba(255,255,255,.02);
|
|
254
|
+
color: var(--text-secondary);
|
|
255
|
+
font-size: 12px;
|
|
256
|
+
line-height: 1.45;
|
|
257
|
+
}
|
|
194
258
|
|
|
195
259
|
/* ── Cards ── */
|
|
196
260
|
|
package/server/public/js/app.js
CHANGED
|
@@ -1278,10 +1278,29 @@ function ensureUpdatePolling(force = false) {
|
|
|
1278
1278
|
}
|
|
1279
1279
|
}
|
|
1280
1280
|
|
|
1281
|
+
function formatInt(n) {
|
|
1282
|
+
return Number(n || 0).toLocaleString();
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function renderTokenUsageSummary(summary) {
|
|
1286
|
+
const el = $("#tokenUsageSummary");
|
|
1287
|
+
if (!el) return;
|
|
1288
|
+
const totals = summary?.totals || {};
|
|
1289
|
+
el.innerHTML = `
|
|
1290
|
+
<div>Total: <strong>${formatInt(totals.totalTokens)}</strong> tokens across <strong>${formatInt(totals.totalRuns)}</strong> runs</div>
|
|
1291
|
+
<div>Last 7 days: <strong>${formatInt(totals.last7DaysTokens)}</strong> tokens in <strong>${formatInt(totals.last7DaysRuns)}</strong> runs</div>
|
|
1292
|
+
<div>Avg/run: <strong>${formatInt(totals.avgTokensPerRun)}</strong> tokens</div>
|
|
1293
|
+
`;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1281
1296
|
$("#settingsBtn").addEventListener("click", async () => {
|
|
1282
1297
|
try {
|
|
1283
|
-
const meta = await
|
|
1284
|
-
|
|
1298
|
+
const [meta, settings, tokenUsage] = await Promise.all([
|
|
1299
|
+
api("/settings/meta/models"),
|
|
1300
|
+
api("/settings"),
|
|
1301
|
+
api("/settings/token-usage/summary")
|
|
1302
|
+
]);
|
|
1303
|
+
renderTokenUsageSummary(tokenUsage);
|
|
1285
1304
|
|
|
1286
1305
|
$("#settingHeartbeat").checked =
|
|
1287
1306
|
settings.heartbeat_enabled === true ||
|
|
@@ -1352,6 +1371,8 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1352
1371
|
} catch (err) {
|
|
1353
1372
|
console.error("Failed to load settings:", err);
|
|
1354
1373
|
$("#settingHeadlessBrowser").checked = true; // default headless
|
|
1374
|
+
const tokenBox = $("#tokenUsageSummary");
|
|
1375
|
+
if (tokenBox) tokenBox.textContent = "Token usage unavailable.";
|
|
1355
1376
|
}
|
|
1356
1377
|
await refreshUpdateStatus();
|
|
1357
1378
|
ensureUpdatePolling(true);
|
|
@@ -2342,6 +2363,27 @@ const MESSAGING_PLATFORMS = [
|
|
|
2342
2363
|
},
|
|
2343
2364
|
];
|
|
2344
2365
|
|
|
2366
|
+
function normalizeWhatsAppWhitelistEntry(value) {
|
|
2367
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
2368
|
+
if (!raw) return "";
|
|
2369
|
+
const base = raw.includes("@") ? raw.split("@")[0] : raw;
|
|
2370
|
+
const primary = base.includes(":") ? base.split(":")[0] : base;
|
|
2371
|
+
const digits = primary.replace(/\D/g, "");
|
|
2372
|
+
return digits || primary;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function normalizeWhatsAppWhitelist(list) {
|
|
2376
|
+
const seen = new Set();
|
|
2377
|
+
const normalized = [];
|
|
2378
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
2379
|
+
const value = normalizeWhatsAppWhitelistEntry(entry);
|
|
2380
|
+
if (!value || seen.has(value)) continue;
|
|
2381
|
+
seen.add(value);
|
|
2382
|
+
normalized.push(value);
|
|
2383
|
+
}
|
|
2384
|
+
return normalized;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2345
2387
|
// Per-platform whitelist config
|
|
2346
2388
|
const PLATFORM_WHITELIST = {
|
|
2347
2389
|
whatsapp: {
|
|
@@ -2353,7 +2395,11 @@ const PLATFORM_WHITELIST = {
|
|
|
2353
2395
|
saveFn: async (list) =>
|
|
2354
2396
|
api("/settings", {
|
|
2355
2397
|
method: "PUT",
|
|
2356
|
-
body: {
|
|
2398
|
+
body: {
|
|
2399
|
+
platform_whitelist_whatsapp: JSON.stringify(
|
|
2400
|
+
normalizeWhatsAppWhitelist(list),
|
|
2401
|
+
),
|
|
2402
|
+
},
|
|
2357
2403
|
}),
|
|
2358
2404
|
},
|
|
2359
2405
|
telnyx: {
|
|
@@ -3240,8 +3286,10 @@ socket.on("messaging:blocked_sender", (data) => {
|
|
|
3240
3286
|
const addBtn = document.getElementById(`wb-add-${bannerId}`);
|
|
3241
3287
|
if (addBtn)
|
|
3242
3288
|
addBtn.addEventListener("click", async () => {
|
|
3243
|
-
const
|
|
3244
|
-
|
|
3289
|
+
const key =
|
|
3290
|
+
platform === "whatsapp"
|
|
3291
|
+
? normalizeWhatsAppWhitelistEntry(rawId)
|
|
3292
|
+
: rawId.replace(/[^0-9]/g, "") || rawId;
|
|
3245
3293
|
try {
|
|
3246
3294
|
await _wbSave(platform, key);
|
|
3247
3295
|
toast(`Added ${key} to whitelist`, "success");
|
package/server/routes/memory.js
CHANGED
|
@@ -88,7 +88,7 @@ router.delete('/memories/:id', (req, res) => {
|
|
|
88
88
|
router.post('/memories/recall', async (req, res) => {
|
|
89
89
|
const mm = req.app.locals.memoryManager;
|
|
90
90
|
const userId = req.session.userId;
|
|
91
|
-
const { query, limit =
|
|
91
|
+
const { query, limit = 8 } = req.body;
|
|
92
92
|
if (!query) return res.status(400).json({ error: 'query is required' });
|
|
93
93
|
try {
|
|
94
94
|
const results = await mm.recallMemory(userId, query, parseInt(limit));
|