nothumanallowed 16.0.22 → 16.0.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "16.0.22",
3
+ "version": "16.0.24",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '16.0.22';
8
+ export const VERSION = '16.0.24';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -155,19 +155,40 @@ class SandboxManager {
155
155
  }
156
156
  emit({ type: 'status', msg: `Entry point: ${entryFile}` });
157
157
 
158
- // ── Phase 2: Dependencies ─────────────────────────────────────────────
158
+ // ── Phase 2: Dependencies (pre-scan + batch install) ──────────────────
159
+ // Pre-scan the project source files for require()/import statements and
160
+ // diff against package.json + node_modules. Install everything missing in
161
+ // ONE batch BEFORE spawning the sandbox, so the Tier 1 retry-on-crash
162
+ // becomes a fallback, not the main code path.
159
163
  if (fs.existsSync(path.join(projectDir, 'package.json'))) {
164
+ const scanned = _scanProjectImports(projectDir);
165
+ const declared = _declaredDeps(projectDir);
166
+ const installed = _installedDeps(projectDir);
167
+ const missing = [...scanned].filter(m => !declared.has(m) && !installed.has(m) && !_SHIMMED_MODULES.has(m));
168
+
169
+ if (missing.length > 0) {
170
+ emit({ type: 'phase', phase: 'deps-prescan', msg: `Pre-scan: ${missing.length} missing module${missing.length === 1 ? '' : 's'} → ${missing.slice(0, 8).join(', ')}${missing.length > 8 ? '...' : ''}` });
171
+ }
172
+
160
173
  emit({ type: 'phase', phase: 'deps', msg: 'Installing dependencies...' });
174
+ const installCmd = missing.length > 0
175
+ ? `npm install --save --prefer-offline --no-audit --no-fund ${missing.map(m => JSON.stringify(m)).join(' ')} 2>&1`
176
+ : 'npm install --prefer-offline --no-audit --no-fund 2>&1';
161
177
  try {
162
- const { stdout } = await execAsync('npm install --prefer-offline --no-audit --no-fund 2>&1', {
178
+ const { stdout } = await execAsync(installCmd, {
163
179
  cwd: projectDir,
164
- timeout: 120_000,
180
+ timeout: 180_000,
165
181
  env: { ...process.env, NODE_ENV: 'development' },
166
182
  });
167
183
  const added = stdout.match(/added (\d+) package/)?.[1] || '0';
168
- emit({ type: 'status', msg: `Dependencies installed (${added} packages)` });
184
+ emit({ type: 'status', msg: `Dependencies installed (${added} packages${missing.length ? `, batch: ${missing.join(', ')}` : ''})` });
169
185
  } catch (e) {
170
- emit({ type: 'warn', msg: `npm install warning: ${e.message.slice(0, 300)}` });
186
+ const diag = _classifyInstallError(e);
187
+ emit({ type: 'warn', msg: `npm install failed — reason: ${diag.reason}. ${diag.hint}` });
188
+ if (diag.offlineFallback) {
189
+ emit({ type: 'status', msg: 'Activating NHA_OFFLINE_SHIM=1 fallback for missing modules.' });
190
+ this._offlineShim = true;
191
+ }
171
192
  }
172
193
  }
173
194
 
@@ -201,14 +222,16 @@ class SandboxManager {
201
222
 
202
223
  // Capture stderr for missing module detection
203
224
  let stderrBuf = '';
225
+ const childEnv = {
226
+ ...process.env,
227
+ PORT: String(port),
228
+ NODE_ENV: 'development',
229
+ NHA_SANDBOX: '1',
230
+ };
231
+ if (this._offlineShim) childEnv.NHA_OFFLINE_SHIM = '1';
204
232
  const proc = spawn('node', [patchedEntry], {
205
233
  cwd: projectDir,
206
- env: {
207
- ...process.env,
208
- PORT: String(port),
209
- NODE_ENV: 'development',
210
- NHA_SANDBOX: '1',
211
- },
234
+ env: childEnv,
212
235
  detached: true,
213
236
  stdio: ['ignore', 'pipe', 'pipe'],
214
237
  });
@@ -291,25 +314,38 @@ class SandboxManager {
291
314
  emit({ type: 'warn', msg: 'Process exited but stderr is EMPTY — could be: process killed by OS, spawn failed before any output, or stdio mis-routed. Run "node .nha-launcher.js" manually in the project dir to reproduce.' });
292
315
  }
293
316
 
294
- // ── Tier 1: missing module → npm install + retry ─────────────────────
295
- const missingMatch = stderrBuf.match(/Cannot find module ['"]([^'"]+)['"]/);
296
- if (missingMatch && _attempt < MAX_RETRIES) {
297
- const missingMod = missingMatch[1];
298
- if (!missingMod.startsWith('.') && !missingMod.startsWith('/') && !missingMod.startsWith('node:')) {
299
- const pkgName = missingMod.startsWith('@') ? missingMod.split('/').slice(0, 2).join('/') : missingMod.split('/')[0];
300
- emit({ type: 'phase', phase: 'autofix', msg: `Missing module "${pkgName}" — installing...` });
301
- try {
302
- await execAsync(`npm install --save ${pkgName} --no-audit --no-fund`, {
303
- cwd: projectDir,
304
- timeout: 60_000,
305
- env: { ...process.env, NODE_ENV: 'development' },
306
- });
307
- emit({ type: 'status', msg: `Installed ${pkgName} — retrying (attempt ${_attempt + 1}/${MAX_RETRIES})...` });
317
+ // ── Tier 1: missing module → batch install all missing + retry ───────
318
+ // The pre-scan in Phase 2 catches most cases. Tier 1 here is the safety
319
+ // net for files generated AFTER pre-scan (e.g. user added a require()
320
+ // during chat) or for transitive crashes that surface only at runtime.
321
+ const missingModules = [...stderrBuf.matchAll(/Cannot find module ['"]([^'"]+)['"]/g)]
322
+ .map(m => m[1])
323
+ .filter(m => !m.startsWith('.') && !m.startsWith('/') && !m.startsWith('node:'))
324
+ .map(m => m.startsWith('@') ? m.split('/').slice(0, 2).join('/') : m.split('/')[0]);
325
+ const uniqueMissing = [...new Set(missingModules)].filter(m => !_SHIMMED_MODULES.has(m));
326
+ if (uniqueMissing.length > 0 && _attempt < MAX_RETRIES) {
327
+ emit({ type: 'phase', phase: 'autofix', msg: `Missing module${uniqueMissing.length === 1 ? '' : 's'}: ${uniqueMissing.join(', ')} — batch installing...` });
328
+ try {
329
+ await execAsync(`npm install --save --no-audit --no-fund ${uniqueMissing.map(m => JSON.stringify(m)).join(' ')}`, {
330
+ cwd: projectDir,
331
+ timeout: 120_000,
332
+ env: { ...process.env, NODE_ENV: 'development' },
333
+ });
334
+ emit({ type: 'status', msg: `Installed ${uniqueMissing.join(', ')} — retrying (attempt ${_attempt + 1}/${MAX_RETRIES})...` });
335
+ return this.start(projectName, projectDir, emit, _attempt + 1);
336
+ } catch (installErr) {
337
+ const diag = _classifyInstallError(installErr);
338
+ emit({ type: 'warn', msg: `Batch install failed — reason: ${diag.reason}. ${diag.hint}` });
339
+ if (diag.offlineFallback) {
340
+ emit({ type: 'status', msg: `Activating NHA_OFFLINE_SHIM=1 fallback — retrying with offline shim...` });
341
+ this._offlineShim = true;
308
342
  return this.start(projectName, projectDir, emit, _attempt + 1);
309
- } catch (installErr) {
310
- emit({ type: 'warn', msg: `Failed to install ${pkgName}: ${installErr.message.slice(0, 200)}` });
311
343
  }
312
344
  }
345
+ } else if (missingModules.length > 0 && uniqueMissing.length === 0) {
346
+ // All missing modules are shimmable — should never reach here because
347
+ // the shim handles them transparently. If it does, log the surprise.
348
+ emit({ type: 'warn', msg: `Crash mentions modules that ARE shimmed (${missingModules.join(', ')}). The shim index.js may not have been preloaded — check .nha-launcher.js wiring.` });
313
349
  }
314
350
 
315
351
  // ── Tier 2: runtime errors that need code fix (require/import mismatch,
@@ -3399,7 +3435,137 @@ function _patchEntry(projectDir, entryFile, shimDir, port) {
3399
3435
  return '.nha-launcher.js';
3400
3436
  }
3401
3437
 
3402
- function _writeShims(shimDir) {
3438
+ // Authoritative list of modules covered by our offline-safe shims.
3439
+ // Used by both the pre-scan (to skip them from npm install) and the Tier 1
3440
+ // retry (to detect "missing module" stderr that's already covered by a shim).
3441
+ export const _SHIMMED_MODULES = new Set([
3442
+ 'pg', 'redis', 'ioredis', 'helmet', 'mongoose', 'sequelize',
3443
+ 'dotenv', 'cors', 'morgan', 'body-parser', 'cookie-parser',
3444
+ 'compression', 'express-rate-limit', 'jsonwebtoken', 'bcryptjs', 'bcrypt',
3445
+ 'uuid', 'lodash', 'debug', 'chalk', 'multer', 'axios',
3446
+ ]);
3447
+
3448
+ /** Read declared dependencies from package.json (deps + devDeps + peer). */
3449
+ function _declaredDeps(projectDir) {
3450
+ const pkgPath = path.join(projectDir, 'package.json');
3451
+ if (!fs.existsSync(pkgPath)) return new Set();
3452
+ try {
3453
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
3454
+ return new Set([
3455
+ ...Object.keys(pkg.dependencies || {}),
3456
+ ...Object.keys(pkg.devDependencies || {}),
3457
+ ...Object.keys(pkg.peerDependencies || {}),
3458
+ ...Object.keys(pkg.optionalDependencies || {}),
3459
+ ]);
3460
+ } catch { return new Set(); }
3461
+ }
3462
+
3463
+ /** List top-level node_modules entries already on disk. */
3464
+ function _installedDeps(projectDir) {
3465
+ const nm = path.join(projectDir, 'node_modules');
3466
+ if (!fs.existsSync(nm)) return new Set();
3467
+ try {
3468
+ const out = new Set();
3469
+ for (const entry of fs.readdirSync(nm)) {
3470
+ if (entry.startsWith('.')) continue;
3471
+ if (entry.startsWith('@')) {
3472
+ const scopedDir = path.join(nm, entry);
3473
+ try {
3474
+ for (const sub of fs.readdirSync(scopedDir)) out.add(`${entry}/${sub}`);
3475
+ } catch {}
3476
+ } else {
3477
+ out.add(entry);
3478
+ }
3479
+ }
3480
+ return out;
3481
+ } catch { return new Set(); }
3482
+ }
3483
+
3484
+ /** Walk project files and extract all bare-import module names. */
3485
+ export function _scanProjectImports(projectDir, maxFiles = 500, maxBytes = 200_000) {
3486
+ const found = new Set();
3487
+ const exts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
3488
+ const skipDirs = new Set(['node_modules', '.git', '.nha-shims', 'dist', 'build', '.next', 'coverage']);
3489
+ const reRequire = /\brequire\s*\(\s*['"]([^'".][^'"]*)['"]\s*\)/g;
3490
+ const reImport = /\bimport\s+(?:[^'"]*\s+from\s+)?['"]([^'".][^'"]*)['"]/g;
3491
+ const reImportSide = /\bimport\s*\(\s*['"]([^'".][^'"]*)['"]\s*\)/g;
3492
+ const stack = [projectDir];
3493
+ let scanned = 0;
3494
+
3495
+ while (stack.length && scanned < maxFiles) {
3496
+ const dir = stack.pop();
3497
+ let entries;
3498
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
3499
+ for (const entry of entries) {
3500
+ if (skipDirs.has(entry.name) || entry.name.startsWith('.')) continue;
3501
+ const full = path.join(dir, entry.name);
3502
+ if (entry.isDirectory()) { stack.push(full); continue; }
3503
+ if (!exts.has(path.extname(entry.name))) continue;
3504
+ let content;
3505
+ try {
3506
+ const stat = fs.statSync(full);
3507
+ if (stat.size > maxBytes) continue;
3508
+ content = fs.readFileSync(full, 'utf-8');
3509
+ } catch { continue; }
3510
+ scanned++;
3511
+ for (const re of [reRequire, reImport, reImportSide]) {
3512
+ re.lastIndex = 0;
3513
+ let m;
3514
+ while ((m = re.exec(content)) !== null) {
3515
+ const spec = m[1];
3516
+ if (spec.startsWith('node:')) continue;
3517
+ const pkg = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0];
3518
+ if (pkg) found.add(pkg);
3519
+ }
3520
+ }
3521
+ }
3522
+ }
3523
+ return found;
3524
+ }
3525
+
3526
+ /** Classify npm install errors into actionable categories. */
3527
+ export function _classifyInstallError(err) {
3528
+ const msg = String(err?.message || err?.stderr || err?.stdout || err || '').toLowerCase();
3529
+ if (/enotfound|etimedout|econnrefused|econnreset|network/.test(msg)) {
3530
+ return { reason: 'offline (npm registry unreachable)', offlineFallback: true,
3531
+ hint: 'Check VM network bridge/NAT, DNS, or corporate proxy (HTTP_PROXY/HTTPS_PROXY). Activating shim fallback.' };
3532
+ }
3533
+ if (/e404|notarget|not found in the npm registry/.test(msg)) {
3534
+ return { reason: 'package does not exist on npm', offlineFallback: false,
3535
+ hint: 'The package name from the LLM-generated code is likely a hallucination. Tier 2 LLM-rewrite will rename it.' };
3536
+ }
3537
+ if (/eacces|eperm|permission denied/.test(msg)) {
3538
+ return { reason: 'permissions denied', offlineFallback: false,
3539
+ hint: 'Run `sudo chown -R $USER ~/.nha` or move the project out of a root-owned directory.' };
3540
+ }
3541
+ if (/engine|unsupported.*node|requires node/.test(msg)) {
3542
+ return { reason: 'Node version mismatch', offlineFallback: false,
3543
+ hint: 'The package requires a different Node version. Check `node --version` and consider using nvm.' };
3544
+ }
3545
+ if (/eintegrity|sha-?(?:1|512) integrity|tarball/.test(msg)) {
3546
+ return { reason: 'package integrity failure', offlineFallback: false,
3547
+ hint: 'Try `rm -rf node_modules package-lock.json && npm install` to clear the cache.' };
3548
+ }
3549
+ return { reason: 'unknown', offlineFallback: false,
3550
+ hint: (err?.message || '').slice(0, 200) };
3551
+ }
3552
+
3553
+ export function _writeShims(shimDir) {
3554
+ // ── Functional offline-safe shims for the 14 most common npm dependencies.
3555
+ // These are NOT no-ops: they implement enough of the real API to keep code
3556
+ // generated by the LLM running even when npm install is unavailable (VMs
3557
+ // without network, corporate proxies, CI cache misses, etc.).
3558
+ //
3559
+ // Categories:
3560
+ // • Storage stubs: pg, redis, ioredis, mongoose, sequelize
3561
+ // • Security stubs: helmet, jsonwebtoken, bcryptjs
3562
+ // • Express middlew: cors, morgan, body-parser, cookie-parser,
3563
+ // compression, express-rate-limit, multer
3564
+ // • Utility stubs: dotenv, uuid, lodash, debug, chalk, axios
3565
+ //
3566
+ // Every shim exports both CJS `module.exports` AND `.default` so it works
3567
+ // under both `require('x')` and `import x from 'x'` interop.
3568
+
3403
3569
  // In-memory pg replacement
3404
3570
  const pgShim = `
3405
3571
  const EventEmitter = require('events');
@@ -3476,35 +3642,471 @@ handler.xssFilter = handler;
3476
3642
  module.exports = handler;
3477
3643
  `;
3478
3644
 
3479
- // Generic no-op shim for unknown enterprise deps
3480
- const noopShim = `module.exports = new Proxy({}, { get: () => new Proxy(() => {}, { get: (_, p) => p === 'then' ? undefined : new Proxy(() => {}, { get: (__, q) => q === 'then' ? undefined : () => {} }) }) });`;
3645
+ // dotenv actually parses .env files and sets process.env
3646
+ const dotenvShim = `
3647
+ const fs = require('fs');
3648
+ const path = require('path');
3649
+ function parse(src) {
3650
+ const out = {};
3651
+ const s = src.toString();
3652
+ const re = /^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(.*?)\\s*$/;
3653
+ for (const raw of s.split(/\\r?\\n/)) {
3654
+ if (!raw || raw.trim().startsWith('#')) continue;
3655
+ const m = raw.match(re);
3656
+ if (!m) continue;
3657
+ let v = m[2];
3658
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
3659
+ v = v.slice(1, -1);
3660
+ }
3661
+ out[m[1]] = v;
3662
+ }
3663
+ return out;
3664
+ }
3665
+ function config(opts) {
3666
+ opts = opts || {};
3667
+ try {
3668
+ const p = opts.path || path.join(process.cwd(), '.env');
3669
+ if (!fs.existsSync(p)) return { parsed: {} };
3670
+ const parsed = parse(fs.readFileSync(p, 'utf-8'));
3671
+ for (const k of Object.keys(parsed)) {
3672
+ if (opts.override || !(k in process.env)) process.env[k] = parsed[k];
3673
+ }
3674
+ return { parsed };
3675
+ } catch (e) { return { error: e }; }
3676
+ }
3677
+ module.exports = { config, parse };
3678
+ module.exports.default = module.exports;
3679
+ `;
3680
+
3681
+ // cors — full Express middleware factory
3682
+ const corsShim = `
3683
+ function cors(opts) {
3684
+ opts = opts || {};
3685
+ const origin = opts.origin === undefined ? '*' : opts.origin;
3686
+ const methods = opts.methods || 'GET,HEAD,PUT,PATCH,POST,DELETE';
3687
+ const credentials = opts.credentials === true;
3688
+ const allowedHeaders = opts.allowedHeaders || 'Content-Type,Authorization';
3689
+ return function (req, res, next) {
3690
+ const o = typeof origin === 'function' ? origin(req) : origin;
3691
+ res.setHeader('Access-Control-Allow-Origin', Array.isArray(o) ? o.join(',') : o);
3692
+ res.setHeader('Access-Control-Allow-Methods', methods);
3693
+ res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
3694
+ if (credentials) res.setHeader('Access-Control-Allow-Credentials', 'true');
3695
+ if (req.method === 'OPTIONS') { res.statusCode = 204; return res.end(); }
3696
+ next();
3697
+ };
3698
+ }
3699
+ module.exports = cors;
3700
+ module.exports.default = cors;
3701
+ `;
3702
+
3703
+ // morgan — minimal request logger
3704
+ const morganShim = `
3705
+ function morgan(format) {
3706
+ return function (req, res, next) {
3707
+ const start = Date.now();
3708
+ res.on('finish', () => {
3709
+ const ms = Date.now() - start;
3710
+ console.log(\`[\${new Date().toISOString()}] \${req.method} \${req.url} \${res.statusCode} \${ms}ms\`);
3711
+ });
3712
+ next();
3713
+ };
3714
+ }
3715
+ morgan.token = () => morgan;
3716
+ morgan.format = () => morgan;
3717
+ module.exports = morgan;
3718
+ module.exports.default = morgan;
3719
+ `;
3720
+
3721
+ // body-parser — JSON + urlencoded
3722
+ const bodyParserShim = `
3723
+ function readBody(req, limit) {
3724
+ return new Promise((resolve, reject) => {
3725
+ let data = '';
3726
+ let size = 0;
3727
+ req.on('data', (chunk) => {
3728
+ size += chunk.length;
3729
+ if (size > limit) { req.destroy(); return reject(new Error('Payload too large')); }
3730
+ data += chunk;
3731
+ });
3732
+ req.on('end', () => resolve(data));
3733
+ req.on('error', reject);
3734
+ });
3735
+ }
3736
+ function jsonMw(opts) {
3737
+ const limit = (opts && opts.limit) ? 1024 * 1024 * 10 : 1024 * 1024;
3738
+ return async function (req, res, next) {
3739
+ if (!/json/i.test(req.headers['content-type'] || '')) return next();
3740
+ try { const raw = await readBody(req, limit); req.body = raw ? JSON.parse(raw) : {}; next(); }
3741
+ catch (e) { res.statusCode = 400; res.end('Invalid JSON'); }
3742
+ };
3743
+ }
3744
+ function urlencodedMw(opts) {
3745
+ return async function (req, res, next) {
3746
+ if (!/x-www-form-urlencoded/i.test(req.headers['content-type'] || '')) return next();
3747
+ try {
3748
+ const raw = await readBody(req, 1024 * 1024);
3749
+ const body = {};
3750
+ for (const pair of raw.split('&')) {
3751
+ const [k, v] = pair.split('=').map(decodeURIComponent);
3752
+ if (k) body[k] = v || '';
3753
+ }
3754
+ req.body = body;
3755
+ next();
3756
+ } catch (e) { res.statusCode = 400; res.end('Invalid form data'); }
3757
+ };
3758
+ }
3759
+ const bp = { json: jsonMw, urlencoded: urlencodedMw, raw: () => (req, res, next) => next(), text: () => (req, res, next) => next() };
3760
+ module.exports = bp;
3761
+ module.exports.default = bp;
3762
+ `;
3763
+
3764
+ // cookie-parser
3765
+ const cookieParserShim = `
3766
+ function parse(str) {
3767
+ const out = {};
3768
+ if (!str) return out;
3769
+ for (const pair of str.split(';')) {
3770
+ const idx = pair.indexOf('=');
3771
+ if (idx < 0) continue;
3772
+ const k = pair.slice(0, idx).trim();
3773
+ const v = pair.slice(idx + 1).trim();
3774
+ out[k] = decodeURIComponent(v);
3775
+ }
3776
+ return out;
3777
+ }
3778
+ function cookieParser() {
3779
+ return function (req, res, next) { req.cookies = parse(req.headers.cookie || ''); next(); };
3780
+ }
3781
+ module.exports = cookieParser;
3782
+ module.exports.default = cookieParser;
3783
+ `;
3784
+
3785
+ // compression — pass-through (no-op middleware, real compression needs zlib)
3786
+ const compressionShim = `
3787
+ function compression() { return function (req, res, next) { next(); }; }
3788
+ module.exports = compression;
3789
+ module.exports.default = compression;
3790
+ `;
3791
+
3792
+ // express-rate-limit — in-memory bucket
3793
+ const rateLimitShim = `
3794
+ function rateLimit(opts) {
3795
+ opts = opts || {};
3796
+ const max = opts.max || 100;
3797
+ const windowMs = opts.windowMs || 60_000;
3798
+ const store = new Map();
3799
+ return function (req, res, next) {
3800
+ const key = (req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'anon') + ':' + (req.path || req.url || '/');
3801
+ const now = Date.now();
3802
+ const rec = store.get(key) || { count: 0, reset: now + windowMs };
3803
+ if (now > rec.reset) { rec.count = 0; rec.reset = now + windowMs; }
3804
+ rec.count++;
3805
+ store.set(key, rec);
3806
+ res.setHeader('X-RateLimit-Limit', String(max));
3807
+ res.setHeader('X-RateLimit-Remaining', String(Math.max(0, max - rec.count)));
3808
+ if (rec.count > max) { res.statusCode = 429; return res.end('Too many requests'); }
3809
+ next();
3810
+ };
3811
+ }
3812
+ module.exports = rateLimit;
3813
+ module.exports.default = rateLimit;
3814
+ `;
3815
+
3816
+ // jsonwebtoken — HS256 sign/verify (sandbox-grade, NOT for production secrets)
3817
+ const jwtShim = `
3818
+ const crypto = require('crypto');
3819
+ function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); }
3820
+ function b64urlDecode(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Buffer.from(s, 'base64'); }
3821
+ function sign(payload, secret, opts) {
3822
+ opts = opts || {};
3823
+ const header = { alg: 'HS256', typ: 'JWT' };
3824
+ const p = Object.assign({}, payload);
3825
+ if (opts.expiresIn) {
3826
+ const sec = typeof opts.expiresIn === 'string' ? parseInt(opts.expiresIn) * (opts.expiresIn.endsWith('h') ? 3600 : opts.expiresIn.endsWith('d') ? 86400 : opts.expiresIn.endsWith('m') ? 60 : 1) : opts.expiresIn;
3827
+ p.exp = Math.floor(Date.now() / 1000) + sec;
3828
+ }
3829
+ const h = b64url(JSON.stringify(header));
3830
+ const b = b64url(JSON.stringify(p));
3831
+ const sig = b64url(crypto.createHmac('sha256', String(secret)).update(h + '.' + b).digest());
3832
+ return h + '.' + b + '.' + sig;
3833
+ }
3834
+ function verify(token, secret) {
3835
+ const parts = String(token).split('.');
3836
+ if (parts.length !== 3) throw new Error('jwt malformed');
3837
+ const [h, b, s] = parts;
3838
+ const expected = b64url(crypto.createHmac('sha256', String(secret)).update(h + '.' + b).digest());
3839
+ if (expected !== s) throw new Error('invalid signature');
3840
+ const payload = JSON.parse(b64urlDecode(b).toString('utf-8'));
3841
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('jwt expired');
3842
+ return payload;
3843
+ }
3844
+ function decode(token) { try { return JSON.parse(b64urlDecode(String(token).split('.')[1]).toString('utf-8')); } catch { return null; } }
3845
+ module.exports = { sign, verify, decode };
3846
+ module.exports.default = module.exports;
3847
+ `;
3848
+
3849
+ // bcryptjs — pbkdf2-based, NOT real bcrypt (sandbox-grade)
3850
+ const bcryptShim = `
3851
+ const crypto = require('crypto');
3852
+ function hashSync(pwd, rounds) {
3853
+ const salt = crypto.randomBytes(16);
3854
+ const iter = Math.pow(2, Math.min(rounds || 10, 14));
3855
+ const hash = crypto.pbkdf2Sync(String(pwd), salt, iter, 32, 'sha256');
3856
+ return '$nha$' + iter + '$' + salt.toString('base64') + '$' + hash.toString('base64');
3857
+ }
3858
+ function compareSync(pwd, stored) {
3859
+ const m = String(stored).match(/^\\$nha\\$(\\d+)\\$([^$]+)\\$(.+)$/);
3860
+ if (!m) return false;
3861
+ const iter = parseInt(m[1]);
3862
+ const salt = Buffer.from(m[2], 'base64');
3863
+ const expected = Buffer.from(m[3], 'base64');
3864
+ const actual = crypto.pbkdf2Sync(String(pwd), salt, iter, 32, 'sha256');
3865
+ return crypto.timingSafeEqual(expected, actual);
3866
+ }
3867
+ async function hash(pwd, rounds) { return hashSync(pwd, rounds); }
3868
+ async function compare(pwd, stored) { return compareSync(pwd, stored); }
3869
+ function genSaltSync() { return 10; }
3870
+ async function genSalt() { return 10; }
3871
+ module.exports = { hash, hashSync, compare, compareSync, genSalt, genSaltSync };
3872
+ module.exports.default = module.exports;
3873
+ `;
3874
+
3875
+ // uuid — v4 only
3876
+ const uuidShim = `
3877
+ const crypto = require('crypto');
3878
+ function v4() {
3879
+ const b = crypto.randomBytes(16);
3880
+ b[6] = (b[6] & 0x0f) | 0x40;
3881
+ b[8] = (b[8] & 0x3f) | 0x80;
3882
+ const h = b.toString('hex');
3883
+ return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
3884
+ }
3885
+ function v1() { return v4(); }
3886
+ function validate(s) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(s)); }
3887
+ module.exports = { v1, v4, validate };
3888
+ module.exports.default = module.exports;
3889
+ `;
3481
3890
 
3482
- fs.writeFileSync(path.join(shimDir, 'pg.js'), pgShim, 'utf-8');
3483
- fs.writeFileSync(path.join(shimDir, 'redis.js'), redisShim, 'utf-8');
3484
- fs.writeFileSync(path.join(shimDir, 'helmet.js'), helmetShim, 'utf-8');
3485
- fs.writeFileSync(path.join(shimDir, 'ioredis.js'), redisShim, 'utf-8');
3486
- fs.writeFileSync(path.join(shimDir, 'mongoose.js'), noopShim, 'utf-8');
3487
- fs.writeFileSync(path.join(shimDir, 'sequelize.js'), noopShim, 'utf-8');
3891
+ // lodash minimal subset (the 95th-percentile-used functions)
3892
+ const lodashShim = `
3893
+ const _ = {};
3894
+ _.isArray = Array.isArray;
3895
+ _.isObject = (x) => x !== null && typeof x === 'object';
3896
+ _.isString = (x) => typeof x === 'string';
3897
+ _.isNumber = (x) => typeof x === 'number' && !isNaN(x);
3898
+ _.isFunction = (x) => typeof x === 'function';
3899
+ _.isEmpty = (x) => x == null || (Array.isArray(x) && x.length === 0) || (typeof x === 'object' && Object.keys(x).length === 0) || (typeof x === 'string' && x.length === 0);
3900
+ _.get = (obj, path, def) => {
3901
+ const keys = Array.isArray(path) ? path : String(path).split('.');
3902
+ let cur = obj;
3903
+ for (const k of keys) { if (cur == null) return def; cur = cur[k]; }
3904
+ return cur === undefined ? def : cur;
3905
+ };
3906
+ _.set = (obj, path, val) => {
3907
+ const keys = Array.isArray(path) ? path : String(path).split('.');
3908
+ let cur = obj;
3909
+ for (let i = 0; i < keys.length - 1; i++) { if (cur[keys[i]] == null) cur[keys[i]] = {}; cur = cur[keys[i]]; }
3910
+ cur[keys[keys.length - 1]] = val;
3911
+ return obj;
3912
+ };
3913
+ _.cloneDeep = (x) => JSON.parse(JSON.stringify(x));
3914
+ _.merge = (target, ...sources) => Object.assign(target, ...sources);
3915
+ _.pick = (obj, keys) => keys.reduce((o, k) => (k in obj ? (o[k] = obj[k], o) : o), {});
3916
+ _.omit = (obj, keys) => Object.keys(obj).reduce((o, k) => (!keys.includes(k) ? (o[k] = obj[k], o) : o), {});
3917
+ _.uniq = (arr) => [...new Set(arr)];
3918
+ _.uniqBy = (arr, fn) => { const seen = new Set(); return arr.filter(x => { const k = typeof fn === 'function' ? fn(x) : x[fn]; if (seen.has(k)) return false; seen.add(k); return true; }); };
3919
+ _.chunk = (arr, n) => { const out = []; for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); return out; };
3920
+ _.flatten = (arr) => arr.flat();
3921
+ _.flattenDeep = (arr) => arr.flat(Infinity);
3922
+ _.groupBy = (arr, fn) => arr.reduce((o, x) => { const k = typeof fn === 'function' ? fn(x) : x[fn]; (o[k] = o[k] || []).push(x); return o; }, {});
3923
+ _.sortBy = (arr, fn) => [...arr].sort((a, b) => { const av = typeof fn === 'function' ? fn(a) : a[fn]; const bv = typeof fn === 'function' ? fn(b) : b[fn]; return av < bv ? -1 : av > bv ? 1 : 0; });
3924
+ _.keyBy = (arr, fn) => arr.reduce((o, x) => { o[typeof fn === 'function' ? fn(x) : x[fn]] = x; return o; }, {});
3925
+ _.debounce = (fn, wait) => { let t; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); }; };
3926
+ _.throttle = (fn, wait) => { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(this, args); } }; };
3927
+ _.range = (start, end, step) => { if (end === undefined) { end = start; start = 0; } step = step || 1; const out = []; for (let i = start; step > 0 ? i < end : i > end; i += step) out.push(i); return out; };
3928
+ _.sum = (arr) => arr.reduce((a, b) => a + b, 0);
3929
+ _.mean = (arr) => arr.length ? _.sum(arr) / arr.length : 0;
3930
+ _.max = (arr) => arr.length ? Math.max(...arr) : undefined;
3931
+ _.min = (arr) => arr.length ? Math.min(...arr) : undefined;
3932
+ _.capitalize = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1).toLowerCase();
3933
+ _.camelCase = (s) => String(s).replace(/[-_\\s]+(.)?/g, (_m, c) => c ? c.toUpperCase() : '');
3934
+ _.kebabCase = (s) => String(s).replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[\\s_]+/g, '-').toLowerCase();
3935
+ _.snakeCase = (s) => String(s).replace(/([a-z])([A-Z])/g, '$1_$2').replace(/[\\s-]+/g, '_').toLowerCase();
3936
+ module.exports = _;
3937
+ module.exports.default = _;
3938
+ `;
3488
3939
 
3489
- // Shim index overrides require() for known modules via Module._resolveFilename
3940
+ // debugnamespace-aware logger
3941
+ const debugShim = `
3942
+ const enabled = (process.env.DEBUG || '').split(',').filter(Boolean);
3943
+ function isEnabled(ns) { return enabled.some(p => p === '*' || ns === p || (p.endsWith('*') && ns.startsWith(p.slice(0, -1)))); }
3944
+ function debug(namespace) {
3945
+ const fn = function (...args) { if (isEnabled(namespace)) console.error('[' + namespace + ']', ...args); };
3946
+ fn.namespace = namespace;
3947
+ fn.enabled = isEnabled(namespace);
3948
+ fn.extend = (ns) => debug(namespace + ':' + ns);
3949
+ return fn;
3950
+ }
3951
+ debug.enable = (ns) => { enabled.push(...ns.split(',')); };
3952
+ debug.disable = () => { enabled.length = 0; };
3953
+ module.exports = debug;
3954
+ module.exports.default = debug;
3955
+ `;
3956
+
3957
+ // chalk — ANSI color codes
3958
+ const chalkShim = `
3959
+ const codes = { reset: [0, 0], bold: [1, 22], dim: [2, 22], italic: [3, 23], underline: [4, 24],
3960
+ black: [30, 39], red: [31, 39], green: [32, 39], yellow: [33, 39], blue: [34, 39], magenta: [35, 39], cyan: [36, 39], white: [37, 39], gray: [90, 39],
3961
+ bgBlack: [40, 49], bgRed: [41, 49], bgGreen: [42, 49], bgYellow: [43, 49], bgBlue: [44, 49] };
3962
+ function wrap(open, close, text) { return '\\x1b[' + open + 'm' + text + '\\x1b[' + close + 'm'; }
3963
+ function build(styles) {
3964
+ const fn = function (...args) {
3965
+ let s = args.join(' ');
3966
+ for (let i = styles.length - 1; i >= 0; i--) { const [o, c] = codes[styles[i]]; s = wrap(o, c, s); }
3967
+ return s;
3968
+ };
3969
+ for (const k of Object.keys(codes)) Object.defineProperty(fn, k, { get: () => build([...styles, k]) });
3970
+ return fn;
3971
+ }
3972
+ const chalk = build([]);
3973
+ chalk.level = 1;
3974
+ chalk.supportsColor = { level: 1, hasBasic: true };
3975
+ module.exports = chalk;
3976
+ module.exports.default = chalk;
3977
+ `;
3978
+
3979
+ // multer — file upload middleware (no-op, no actual disk write)
3980
+ const multerShim = `
3981
+ function multer(opts) {
3982
+ return {
3983
+ single: () => (req, res, next) => { req.file = null; next(); },
3984
+ array: () => (req, res, next) => { req.files = []; next(); },
3985
+ fields: () => (req, res, next) => { req.files = {}; next(); },
3986
+ any: () => (req, res, next) => { req.files = []; next(); },
3987
+ none: () => (req, res, next) => next(),
3988
+ };
3989
+ }
3990
+ multer.diskStorage = (opts) => ({ _kind: 'disk', opts });
3991
+ multer.memoryStorage = () => ({ _kind: 'memory' });
3992
+ module.exports = multer;
3993
+ module.exports.default = multer;
3994
+ `;
3995
+
3996
+ // axios — minimal fetch-based replacement
3997
+ const axiosShim = `
3998
+ async function request(config) {
3999
+ const url = typeof config === 'string' ? config : config.url;
4000
+ const method = (typeof config === 'object' && config.method) || 'GET';
4001
+ const headers = (typeof config === 'object' && config.headers) || {};
4002
+ const body = typeof config === 'object' ? config.data : undefined;
4003
+ const init = { method, headers: { ...headers }, };
4004
+ if (body !== undefined) {
4005
+ if (typeof body === 'object' && !(body instanceof URLSearchParams)) {
4006
+ init.body = JSON.stringify(body);
4007
+ if (!init.headers['Content-Type']) init.headers['Content-Type'] = 'application/json';
4008
+ } else { init.body = body; }
4009
+ }
4010
+ const res = await fetch(url, init);
4011
+ const ct = res.headers.get('content-type') || '';
4012
+ const data = ct.includes('json') ? await res.json().catch(() => null) : await res.text();
4013
+ if (res.status >= 400) { const err = new Error('Request failed with status code ' + res.status); err.response = { status: res.status, data, headers: Object.fromEntries(res.headers) }; throw err; }
4014
+ return { data, status: res.status, statusText: res.statusText, headers: Object.fromEntries(res.headers) };
4015
+ }
4016
+ const axios = function (config) { return request(config); };
4017
+ axios.request = request;
4018
+ axios.get = (url, config) => request({ ...config, url, method: 'GET' });
4019
+ axios.post = (url, data, config) => request({ ...config, url, method: 'POST', data });
4020
+ axios.put = (url, data, config) => request({ ...config, url, method: 'PUT', data });
4021
+ axios.patch = (url, data, config) => request({ ...config, url, method: 'PATCH', data });
4022
+ axios.delete = (url, config) => request({ ...config, url, method: 'DELETE' });
4023
+ axios.create = (defaults) => axios;
4024
+ module.exports = axios;
4025
+ module.exports.default = axios;
4026
+ `;
4027
+
4028
+ // Generic no-op shim — deeply chainable proxy. Survives any .a().b.c().d
4029
+ // access chain because every get/apply returns another proxy of the same shape.
4030
+ const noopShim = `
4031
+ const noop = function () {};
4032
+ const handler = {
4033
+ get(target, prop) {
4034
+ if (prop === 'then' || prop === Symbol.toPrimitive || prop === Symbol.iterator) return undefined;
4035
+ if (prop === 'default') return proxy;
4036
+ if (prop === 'toString') return () => '[nha-noop]';
4037
+ return proxy;
4038
+ },
4039
+ apply() { return proxy; },
4040
+ construct() { return proxy; },
4041
+ };
4042
+ const proxy = new Proxy(noop, handler);
4043
+ module.exports = proxy;
4044
+ module.exports.default = proxy;
4045
+ `;
4046
+
4047
+ const shimFiles = {
4048
+ 'pg.js': pgShim,
4049
+ 'redis.js': redisShim,
4050
+ 'ioredis.js': redisShim,
4051
+ 'helmet.js': helmetShim,
4052
+ 'mongoose.js': noopShim,
4053
+ 'sequelize.js': noopShim,
4054
+ 'dotenv.js': dotenvShim,
4055
+ 'cors.js': corsShim,
4056
+ 'morgan.js': morganShim,
4057
+ 'body-parser.js': bodyParserShim,
4058
+ 'cookie-parser.js': cookieParserShim,
4059
+ 'compression.js': compressionShim,
4060
+ 'express-rate-limit.js': rateLimitShim,
4061
+ 'jsonwebtoken.js': jwtShim,
4062
+ 'bcryptjs.js': bcryptShim,
4063
+ 'bcrypt.js': bcryptShim,
4064
+ 'uuid.js': uuidShim,
4065
+ 'lodash.js': lodashShim,
4066
+ 'debug.js': debugShim,
4067
+ 'chalk.js': chalkShim,
4068
+ 'multer.js': multerShim,
4069
+ 'axios.js': axiosShim,
4070
+ 'noop.js': noopShim,
4071
+ };
4072
+ for (const [name, content] of Object.entries(shimFiles)) {
4073
+ fs.writeFileSync(path.join(shimDir, name), content, 'utf-8');
4074
+ }
4075
+
4076
+ // Shim index — only activates a shim if the REAL package is missing.
4077
+ // This lets a project that has its own dotenv/cors/etc. installed use the
4078
+ // real implementation, and only falls back to our shim when resolution fails.
4079
+ // Also supports NHA_OFFLINE_SHIM=1 to no-op any unresolvable module.
4080
+ const shimList = JSON.stringify(Object.keys(shimFiles).filter(f => f !== 'noop.js').map(f => f.replace(/\.js$/, '')));
3490
4081
  const shimIndex = `
3491
4082
  const Module = require('module');
3492
4083
  const path = require('path');
3493
4084
  const __shimDir = ${JSON.stringify(shimDir)};
4085
+ const SHIM_NAMES = new Set(${shimList});
4086
+ const ALIAS = { 'bcrypt': 'bcryptjs' };
4087
+ const OFFLINE = process.env.NHA_OFFLINE_SHIM === '1';
4088
+ const _original = Module._resolveFilename.bind(Module);
3494
4089
 
3495
- const SHIMS = {
3496
- 'pg': path.join(__shimDir, 'pg.js'),
3497
- 'redis': path.join(__shimDir, 'redis.js'),
3498
- 'ioredis': path.join(__shimDir, 'redis.js'),
3499
- 'helmet': path.join(__shimDir, 'helmet.js'),
3500
- 'mongoose': path.join(__shimDir, 'mongoose.js'),
3501
- 'sequelize': path.join(__shimDir, 'sequelize.js'),
3502
- };
4090
+ function shimPath(name) {
4091
+ const f = (ALIAS[name] || name) + '.js';
4092
+ return path.join(__shimDir, f);
4093
+ }
3503
4094
 
3504
- const _original = Module._resolveFilename.bind(Module);
3505
4095
  Module._resolveFilename = function(request, parent, isMain, options) {
3506
- if (SHIMS[request]) return SHIMS[request];
3507
- return _original(request, parent, isMain, options);
4096
+ try {
4097
+ return _original(request, parent, isMain, options);
4098
+ } catch (err) {
4099
+ if (err && err.code === 'MODULE_NOT_FOUND') {
4100
+ if (SHIM_NAMES.has(request) || ALIAS[request]) {
4101
+ return shimPath(request);
4102
+ }
4103
+ if (OFFLINE && !request.startsWith('.') && !request.startsWith('/') && !request.startsWith('node:')) {
4104
+ try { console.error('[nha-shim] offline-noop for missing module: ' + request); } catch {}
4105
+ return path.join(__shimDir, 'noop.js');
4106
+ }
4107
+ }
4108
+ throw err;
4109
+ }
3508
4110
  };
3509
4111
  `;
3510
4112
  fs.writeFileSync(path.join(shimDir, 'index.js'), shimIndex, 'utf-8');
@@ -774,6 +774,43 @@ class TelegramResponder {
774
774
  this.log('[Telegram] Responder stopped');
775
775
  }
776
776
 
777
+ /**
778
+ * Send a Telegram message that may exceed the 4096-char API limit.
779
+ * Splits on paragraph/line boundaries when possible, never on a multi-byte
780
+ * sequence. Sends in order with a small delay to avoid rate-limit 429.
781
+ * Returns the array of message IDs sent.
782
+ */
783
+ async _sendMessageSafe(chatId, text, extraOpts = {}) {
784
+ const TG_MAX = 4000; // safety margin from 4096 to account for emoji weight
785
+ const str = String(text == null ? '' : text);
786
+ if (str.length <= TG_MAX) {
787
+ return [await this._telegramCall('sendMessage', { chat_id: chatId, text: str, ...extraOpts })];
788
+ }
789
+ // Split intelligently: try paragraph breaks, then lines, then hard slice.
790
+ const chunks = [];
791
+ let remaining = str;
792
+ while (remaining.length > TG_MAX) {
793
+ let cutAt = remaining.lastIndexOf('\n\n', TG_MAX);
794
+ if (cutAt < TG_MAX / 2) cutAt = remaining.lastIndexOf('\n', TG_MAX);
795
+ if (cutAt < TG_MAX / 2) cutAt = remaining.lastIndexOf(' ', TG_MAX);
796
+ if (cutAt < TG_MAX / 2) cutAt = TG_MAX;
797
+ chunks.push(remaining.slice(0, cutAt).trim());
798
+ remaining = remaining.slice(cutAt).trim();
799
+ }
800
+ if (remaining) chunks.push(remaining);
801
+ const ids = [];
802
+ for (let i = 0; i < chunks.length; i++) {
803
+ const part = chunks.length > 1 ? `(${i + 1}/${chunks.length})\n${chunks[i]}` : chunks[i];
804
+ try {
805
+ ids.push(await this._telegramCall('sendMessage', { chat_id: chatId, text: part, ...extraOpts }));
806
+ } catch (e) {
807
+ this.log(`[Telegram] sendMessage chunk ${i + 1}/${chunks.length} failed: ${e.message}`);
808
+ }
809
+ await this._sleep(180); // avoid 429 rate limit
810
+ }
811
+ return ids;
812
+ }
813
+
777
814
  async _scheduleUpdateCheck() {
778
815
  await this._checkAndNotifyUpdate();
779
816
  await this._checkLocalUpdateAndRestart();
@@ -834,14 +871,10 @@ class TelegramResponder {
834
871
 
835
872
  for (const chatId of chatIds) {
836
873
  try {
837
- await this._telegramCall('sendMessage', {
838
- chat_id: parseInt(chatId, 10),
839
- text: msg,
840
- });
874
+ await this._sendMessageSafe(parseInt(chatId, 10), msg);
841
875
  } catch {
842
876
  // User blocked bot or chat no longer exists — ignore
843
877
  }
844
- // Small delay to avoid Telegram rate limits
845
878
  await this._sleep(300);
846
879
  }
847
880
  } catch (err) {
@@ -1037,12 +1070,11 @@ class TelegramResponder {
1037
1070
  const prefix = personaMode === 'persona-only' && personaName ? ''
1038
1071
  : personaName ? `[${personaName}]\n\n`
1039
1072
  : `[HERALD]\n\n`;
1040
- await this._telegramCall('sendMessage', { chat_id: chatId, text: prefix + truncated });
1073
+ await this._sendMessageSafe(chatId, prefix + description);
1041
1074
  this.log(`[Telegram] Image vision response to ${fromUser} (${buf.length} bytes, ${description.length} chars)`);
1042
1075
  } catch (err) {
1043
1076
  this.log(`[Telegram] Vision failed: ${err.message}`);
1044
- await this._telegramCall('sendMessage', { chat_id: chatId,
1045
- text: `Non riesco ad analizzare l'immagine: ${err.message}` }).catch(() => {});
1077
+ await this._sendMessageSafe(chatId, `Non riesco ad analizzare l'immagine: ${err.message}`).catch(() => {});
1046
1078
  }
1047
1079
  return;
1048
1080
  }
@@ -1055,16 +1087,13 @@ class TelegramResponder {
1055
1087
  await this._telegramCall('sendChatAction', { chat_id: chatId, action: 'typing' });
1056
1088
  rawText = await this._transcribeVoice(fileId);
1057
1089
  if (!rawText.trim()) {
1058
- await this._telegramCall('sendMessage', { chat_id: chatId, text: 'Non ho capito il vocale. Riprova.' });
1090
+ await this._sendMessageSafe(chatId, 'Non ho capito il vocale. Riprova.');
1059
1091
  return;
1060
1092
  }
1061
1093
  this.log(`[Telegram] Voice transcribed for ${fromUser}: "${rawText.slice(0, 80)}"`);
1062
1094
  } catch (err) {
1063
1095
  this.log(`[Telegram] Voice transcription failed: ${err.message}`);
1064
- await this._telegramCall('sendMessage', {
1065
- chat_id: chatId,
1066
- text: `Non riesco a trascrivere il vocale (${err.message}).\n\nPer abilitare la trascrizione vocale gratuita, dal computer esegui:\nnha config set groqKey TUA_CHIAVE_GROQ\n\nLa chiave si ottiene gratis su https://console.groq.com/keys`,
1067
- });
1096
+ await this._sendMessageSafe(chatId, `Non riesco a trascrivere il vocale (${err.message}).\n\nPer abilitare la trascrizione vocale gratuita, dal computer esegui:\nnha config set groqKey TUA_CHIAVE_GROQ\n\nLa chiave si ottiene gratis su https://console.groq.com/keys`);
1068
1097
  return;
1069
1098
  }
1070
1099
  }
@@ -1082,10 +1111,7 @@ class TelegramResponder {
1082
1111
 
1083
1112
  // If voice: show transcription so user knows what was understood
1084
1113
  if (isVoice) {
1085
- await this._telegramCall('sendMessage', {
1086
- chat_id: chatId,
1087
- text: `🎤 "${cleanText}"`,
1088
- }).catch(() => {});
1114
+ await this._sendMessageSafe(chatId, `🎤 "${cleanText}"`).catch(() => {});
1089
1115
  }
1090
1116
 
1091
1117
  this.pendingRequests++;
@@ -1183,7 +1209,7 @@ class TelegramResponder {
1183
1209
  } else {
1184
1210
  reply = `[HERALD]\n\n${directResult.message}`;
1185
1211
  }
1186
- await this._telegramCall('sendMessage', { chat_id: chatId, text: reply });
1212
+ await this._sendMessageSafe(chatId, reply);
1187
1213
 
1188
1214
  // Update rolling memory + reset pending action (so a follow-up
1189
1215
  // "Si" doesn't try to delete a second time).
@@ -1310,7 +1336,7 @@ class TelegramResponder {
1310
1336
  } else {
1311
1337
  reply = `[HERALD]\n\n${directFresh.message}`;
1312
1338
  }
1313
- await this._telegramCall('sendMessage', { chat_id: chatId, text: reply });
1339
+ await this._sendMessageSafe(chatId, reply);
1314
1340
  const MAX = 20;
1315
1341
  const prevLog = (lastCtx && Array.isArray(lastCtx.conversationLog)) ? lastCtx.conversationLog : [];
1316
1342
  this._lastContextByChatId[chatId] = {
@@ -1437,19 +1463,13 @@ class TelegramResponder {
1437
1463
  prefixedText = `[${agentLabel}]\n\n${truncated}`;
1438
1464
  }
1439
1465
 
1440
- await this._telegramCall('sendMessage', {
1441
- chat_id: chatId,
1442
- text: prefixedText,
1443
- });
1466
+ await this._sendMessageSafe(chatId, prefixedText);
1444
1467
 
1445
1468
  this.log(`[Telegram] Responded to ${fromUser} via ${agentLabel}${personaName ? ` (as "${personaName}")` : ''} (${responseText.length} chars)${isCompletedAction(responseText) ? ' [action completed — context reset]' : ''}`);
1446
1469
  } catch (err) {
1447
1470
  this.log(`[Telegram] Agent call failed: ${err.message}`);
1448
1471
  // Send error message to user
1449
- await this._telegramCall('sendMessage', {
1450
- chat_id: chatId,
1451
- text: `Error: ${err.message}`,
1452
- }).catch(() => {});
1472
+ await this._sendMessageSafe(chatId, `Error: ${err.message}`).catch(() => {});
1453
1473
  } finally {
1454
1474
  this.pendingRequests--;
1455
1475
  }
@@ -2598,6 +2618,63 @@ class TelegramResponder {
2598
2618
  const chatId = this._lastDirectAuditChatId;
2599
2619
  const { executeTool: _executeToolPre } = await import('./tool-executor.mjs');
2600
2620
 
2621
+ // ─── PAGINATION: "mostra i prossimi / vai avanti / i restanti" ─────────
2622
+ // Deterministic next-page fetch from the cached lastCalendarEvents instead
2623
+ // of letting the LLM hallucinate "the remaining events" (Giovanni
2624
+ // Santaniello's bug: NHA inventing fake events when asked for page 2).
2625
+ const isPaginationRequest = /\b(mostra(?:mi)?\s+(?:i\s+)?(?:prossimi|altri|restanti|seguenti)|vai\s+avanti|continua\s+(?:l[ai']\s+)?(?:lista|elenco)|i\s+(?:prossimi|altri|restanti|seguenti)\s+\d*|gli\s+altri|dammi\s+(?:i\s+)?(?:prossimi|restanti|altri)|next\s+page|next\s+\d+|show\s+more|continue\b)/i.test(userMessage);
2626
+ if (isPaginationRequest) {
2627
+ // Pull from list-cache (works across all kinds) — calendar first.
2628
+ try {
2629
+ const cacheFile = path.join(os.homedir(), '.nha', 'list-cache.json');
2630
+ if (fs.existsSync(cacheFile)) {
2631
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
2632
+ // Find the most recent calendar list across all chats
2633
+ let allItems = []; let shown = 0; let foundChatKey = null;
2634
+ for (const [k, v] of Object.entries(cache)) {
2635
+ if (Array.isArray(v?.lastList_calendar) && v.lastList_calendar.length > 0) {
2636
+ if (!foundChatKey || (v.lastList_calendar_at || 0) > (cache[foundChatKey]?.lastList_calendar_at || 0)) {
2637
+ foundChatKey = k;
2638
+ allItems = v.lastList_calendar;
2639
+ shown = v.lastList_calendar_shownCount || 0;
2640
+ }
2641
+ }
2642
+ }
2643
+ if (allItems.length > 0 && shown < allItems.length) {
2644
+ // Page size from regex capture or default 8
2645
+ const numMatch = userMessage.match(/\b(\d+)\b/);
2646
+ const pageSize = numMatch ? Math.min(parseInt(numMatch[1], 10), 20) : 8;
2647
+ const nextSlice = allItems.slice(shown, shown + pageSize);
2648
+ const lines = [`📅 Eventi ${shown + 1}-${shown + nextSlice.length} di ${allItems.length}:`];
2649
+ const byDay = new Map();
2650
+ for (const e of nextSlice) {
2651
+ const day = e.date || (e.start || '').slice(0, 10) || 'misc';
2652
+ if (!byDay.has(day)) byDay.set(day, []);
2653
+ byDay.get(day).push(e);
2654
+ }
2655
+ for (const [day, evs] of [...byDay.entries()].sort()) {
2656
+ const d = day !== 'misc' ? new Date(day + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'short', day: 'numeric', month: 'short' }) : '';
2657
+ if (d) lines.push(`\n${d}:`);
2658
+ for (const e of evs) lines.push(` ${e.time || '—'} — ${e.summary}`);
2659
+ }
2660
+ const newShown = shown + nextSlice.length;
2661
+ const remaining = allItems.length - newShown;
2662
+ if (remaining > 0) lines.push(`\n... ${remaining} eventi rimanenti. Scrivi "mostra i prossimi" per continuare.`);
2663
+ else lines.push(`\n✓ Fine elenco.`);
2664
+ // Update shownCount in cache
2665
+ cache[foundChatKey].lastList_calendar_shownCount = newShown;
2666
+ fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
2667
+ this.log(`[direct] PAGINATION calendar: shown ${shown}→${newShown} of ${allItems.length}`);
2668
+ return { action: 'calendar_page', success: true, message: lines.join('\n') };
2669
+ }
2670
+ if (allItems.length > 0 && shown >= allItems.length) {
2671
+ return { action: 'calendar_page', success: true, message: '✓ Hai già visto tutti gli eventi. Scrivi "appuntamenti di oggi/settimana/maggio" per una nuova ricerca.' };
2672
+ }
2673
+ }
2674
+ } catch (e) { this.log(`[direct] pagination failed: ${e.message}`); }
2675
+ // Fall through if no cached list
2676
+ }
2677
+
2601
2678
  // ─── ANAPHORIC delete + CONFIRMATION yes ────────────────────────────────
2602
2679
  // If the previous turn ran a LIST/LAST-SHOWN and the user now says
2603
2680
  // "cancellalo / eliminalo / quello / si / conferma / fallo", resolve the
@@ -2875,6 +2952,16 @@ class TelegramResponder {
2875
2952
  const runListAndRemember = async (toolName, args, actionKey) => {
2876
2953
  try {
2877
2954
  const out = await executeTool(toolName, args, config);
2955
+ // Reset pagination state for this kind: a NEW list starts fresh.
2956
+ try {
2957
+ const cacheFile = path.join(os.homedir(), '.nha', 'list-cache.json');
2958
+ if (fs.existsSync(cacheFile)) {
2959
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
2960
+ const persistKey = chatId || '__last_list__';
2961
+ if (cache[persistKey]) delete cache[persistKey].lastList_calendar_shownCount;
2962
+ fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
2963
+ }
2964
+ } catch {}
2878
2965
  // Parse from text first (cheap, works when tools include event IDs).
2879
2966
  let events = this._parseEventsFromToolOutput(String(out));
2880
2967
  // Fallback: tools like calendar_month don't print event IDs in their
@@ -2909,6 +2996,37 @@ class TelegramResponder {
2909
2996
  };
2910
2997
  this.log(`[direct] LIST stored: chatId=${persistKey} eventsCount=${events.length} tool=${toolName}`);
2911
2998
  try { saveTelegramContext(this._lastContextByChatId); } catch (e) { this.log(`[direct] persist FAILED: ${e.message}`); }
2999
+
3000
+ // ── PAGINATION CAP (Giovanni's bug, v16.0.23) ──
3001
+ // Telegram limits messages to 4096 chars. If the list has many events,
3002
+ // show first 8 with footer "scrivi 'mostra i prossimi' per continuare"
3003
+ // and persist shownCount in list-cache so pagination is deterministic.
3004
+ const PAGE_SIZE = 8;
3005
+ if (events.length > PAGE_SIZE) {
3006
+ const firstPage = events.slice(0, PAGE_SIZE);
3007
+ const lines = [`📅 ${firstPage.length} eventi (di ${events.length} totali):`];
3008
+ const byDay = new Map();
3009
+ for (const e of firstPage) {
3010
+ const day = e.date || (e.start || '').slice(0, 10) || 'misc';
3011
+ if (!byDay.has(day)) byDay.set(day, []);
3012
+ byDay.get(day).push(e);
3013
+ }
3014
+ for (const [day, evs] of [...byDay.entries()].sort()) {
3015
+ const d = day !== 'misc' ? new Date(day + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'short', day: 'numeric', month: 'short' }) : '';
3016
+ if (d) lines.push(`\n${d}:`);
3017
+ for (const e of evs) lines.push(` ${e.time || '—'} — ${e.summary}`);
3018
+ }
3019
+ lines.push(`\n... ${events.length - PAGE_SIZE} eventi rimanenti. Scrivi "mostra i prossimi" per continuare.`);
3020
+ // Mark shownCount in list-cache so the pagination handler picks up from here.
3021
+ try {
3022
+ const cacheFile = path.join(os.homedir(), '.nha', 'list-cache.json');
3023
+ const cache = fs.existsSync(cacheFile) ? JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) : {};
3024
+ cache[persistKey] = cache[persistKey] || {};
3025
+ cache[persistKey].lastList_calendar_shownCount = PAGE_SIZE;
3026
+ fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
3027
+ } catch {}
3028
+ return { action: actionKey, success: true, message: lines.join('\n') };
3029
+ }
2912
3030
  return { action: actionKey, success: true, message: String(out) };
2913
3031
  } catch (e) { return { action: actionKey, success: false, message: `Errore: ${e.message}` }; }
2914
3032
  };