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 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; update skipped. Use npm update when installed from npm.');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "MIT",
6
6
  "main": "server/index.js",
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, detectPromptInjection } = require('./utils/security');
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
- const logHistory = [];
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); // trust first hop (reverse proxy)
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
- // Disable headers that only make sense on HTTPS — this app runs on plain HTTP (Tailscale/localhost).
85
- strictTransportSecurity: false, // HSTS: would force browser to upgrade HTTP→HTTPS permanently
86
- crossOriginOpenerPolicy: false, // COOP: ignored on non-HTTPS origins, causes browser warning
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'"], // explicit: prevents form-submission to external origins
98
- frameAncestors: ["'self'"], // explicit: prevents iframe embedding from other origins
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 // true when behind HTTPS proxy; false for plain HTTP (Tailscale/localhost)
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
- // Protected by a shared-secret token in the query string.
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
- // Must be registered after all routes. Sanitizes error details so internal paths
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 ──
@@ -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
 
@@ -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 api("/settings/meta/models");
1284
- const settings = await api("/settings");
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: { platform_whitelist_whatsapp: JSON.stringify(list) },
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 digits = rawId.replace(/[^0-9]/g, "");
3244
- const key = digits || rawId;
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");
@@ -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 = 10 } = req.body;
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));