nothumanallowed 15.1.4 → 15.1.5

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": "15.1.4",
3
+ "version": "15.1.5",
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 = '15.0.12';
8
+ export const VERSION = '15.1.5';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -9,7 +9,7 @@ import { sendJSON, sendError, parseBody } from '../index.mjs';
9
9
  import { loadConfig } from '../../config.mjs';
10
10
  import { NHA_DIR, AGENTS_DIR } from '../../constants.mjs';
11
11
  import { callLLM, callLLMStream, parseAgentFile } from '../../services/llm.mjs';
12
- import { webSearch, fetchUrl } from '../../services/web-tools.mjs';
12
+ import { webSearch, fetchUrl, fetchUrlRich, headProbe } from '../../services/web-tools.mjs';
13
13
 
14
14
  // Agents that get web data pre-fetched before their LLM call
15
15
  const WEB_TOOL_AGENTS = new Set([
@@ -94,20 +94,65 @@ async function runWebSearch(query) {
94
94
  }
95
95
 
96
96
  /**
97
- * Fetch a URL and return formatted content string.
97
+ * Fetch a URL and return a structured result. Caller decides how to format it.
98
+ * Returns { ok, url, content?, title?, metadata?, code?, reason? }.
98
99
  */
99
100
  async function runFetchUrl(url) {
100
101
  try {
101
- const result = await fetchUrl(url);
102
- if (result.error) return `Fetch failed: ${result.message}`;
103
- const titlePart = result.title ? `Title: ${result.title}\n\n` : '';
104
- const text = (result.body || '').slice(0, 5000);
105
- return `Content from ${url}:\n\n${titlePart}${text}`;
102
+ const result = await fetchUrlRich(url, { maxChars: 16_000 });
103
+ if (result.error) {
104
+ return { ok: false, url, code: result.code || 'FETCH_FAILED', reason: result.message || 'unknown' };
105
+ }
106
+ const meta = result.metadata || {};
107
+ const ogBlock = Object.keys(meta.og || {}).length
108
+ ? `OpenGraph: ${Object.entries(meta.og).slice(0, 8).map(([k, v]) => `${k}=${String(v).slice(0, 200)}`).join(' | ')}\n`
109
+ : '';
110
+ const ldBlock = (meta.jsonLd || []).length
111
+ ? `Schema.org types: ${meta.jsonLd.slice(0, 6).map(j => j.type).join(', ')}\n`
112
+ : '';
113
+ const headingsBlock = meta.headings && (meta.headings.h1?.length || meta.headings.h2?.length)
114
+ ? `Headings H1: ${(meta.headings.h1 || []).slice(0, 5).join(' | ')}\n` +
115
+ (meta.headings.h2?.length ? `Headings H2: ${meta.headings.h2.slice(0, 8).join(' | ')}\n` : '')
116
+ : '';
117
+ const descBlock = meta.description ? `Description: ${meta.description}\n` : '';
118
+ return {
119
+ ok: true,
120
+ url: result.url || url,
121
+ title: result.title || '',
122
+ content: result.body || '',
123
+ metadata: meta,
124
+ preamble: `${result.title ? `Title: ${result.title}\n` : ''}${descBlock}${ogBlock}${ldBlock}${headingsBlock}`.trim(),
125
+ };
106
126
  } catch (e) {
107
- return `Fetch error: ${e.message}`;
127
+ return { ok: false, url, code: 'EXCEPTION', reason: e.message || String(e) };
108
128
  }
109
129
  }
110
130
 
131
+ /** Extract HTTP/HTTPS URLs from a free-form string, deduplicated, max 6. */
132
+ function extractUrlsFromText(text) {
133
+ const urls = [...(text || '').matchAll(/https?:\/\/[^\s,)<>"'`]+/gi)].map(m => m[0].replace(/[.,;:!?)\]]+$/, ''));
134
+ return [...new Set(urls)].slice(0, 6);
135
+ }
136
+
137
+ /**
138
+ * Strip transient status markers, internal think/tool blocks, and propagation
139
+ * noise before the text is injected into another agent's prompt or surfaced
140
+ * to the client. Parity with LegionX `CommunicationStream.stripTags`.
141
+ */
142
+ function sanitizeAgentOutput(text) {
143
+ if (!text) return '';
144
+ return String(text)
145
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
146
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
147
+ .replace(/<tool[^>]*>[\s\S]*?<\/tool>/gi, '')
148
+ .replace(/<scratch[^>]*>[\s\S]*?<\/scratch>/gi, '')
149
+ .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
150
+ .replace(/\[(?:Searching|Fetching|GET|POST|HEAD|Raccolta dati web|Parlamento)[^\]]*\]\s*/gi, '')
151
+ .replace(/​|/g, '') // zero-width / BOM
152
+ .replace(/\r\n/g, '\n')
153
+ .trim();
154
+ }
155
+
111
156
  /**
112
157
  * Extract search queries from a task string.
113
158
  * Returns up to 3 queries covering different angles.
@@ -257,8 +302,24 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
257
302
  } catch {}
258
303
  }
259
304
 
260
- const contextBlock = context ? `\n\n## CONTEXT FROM PREVIOUS STEPS:\n${context.slice(0, 12000)}` : '';
261
- const proposalContextBlock = body.proposalContext ? `\n\n## OTHER AGENTS' PROPOSALS (CROSS-READING):\n${body.proposalContext.slice(0, 8000)}` : '';
305
+ const contextBlock = context ? `\n\n## CONTEXT FROM PREVIOUS STEPS:\n${sanitizeAgentOutput(context).slice(0, 12000)}` : '';
306
+
307
+ // Cross-reading block — when other agents have already produced proposals,
308
+ // we inject them with explicit deliberation instructions so the model
309
+ // engages with them (agree/disagree) rather than restating its own view.
310
+ const proposalContextBlock = body.proposalContext ? `
311
+
312
+ ## ALTRE PROPOSTE GIÀ COMPLETATE (CROSS-READING)
313
+
314
+ I seguenti output sono proposte di altri agenti che hanno lavorato sullo stesso task. Leggile attentamente prima di rispondere.
315
+
316
+ ${sanitizeAgentOutput(body.proposalContext).slice(0, 12000)}
317
+
318
+ ### ISTRUZIONI DI DELIBERAZIONE (obbligatorie)
319
+ 1. **Identifica i punti in cui CONCORDI** con un agente precedente → marca con \`[✓ CONCORDA CON: NomeAgente]\` e cita il punto specifico.
320
+ 2. **Identifica i punti in cui DISSENTI** → marca con \`[✗ DISACCORDO CON: NomeAgente]\` e spiega il motivo con evidenze concrete (dai dati fetched, non opinioni).
321
+ 3. **NON ripetere ciò che hanno già detto bene** — aggiungi il TUO contributo specifico dalla TUA specializzazione.
322
+ 4. **Se i dati raccolti contraddicono un agente precedente**, segnalalo esplicitamente.` : '';
262
323
 
263
324
  const formatInstructions = `\n\nFORMATTING RULES (CRITICAL — your output will be rendered as HTML):
264
325
  - Use MARKDOWN TABLES with | pipes | for ALL tabular data. Example:
@@ -284,27 +345,62 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
284
345
  const userMessage = stepDef?.prompt || task;
285
346
 
286
347
  const useWebTools = WEB_TOOL_AGENTS.has(agent);
348
+ const isPrimaryResearch = !!body.isPrimaryResearch || !!stepDef?.isPrimaryResearch;
287
349
  let webDataBlock = '';
350
+ const fetchOutcomes = { ok: 0, failed: 0, attempted: 0, failures: [] };
288
351
 
289
- // ALWAYS pre-fetch URLs found in the task — every agent needs real data
290
- const taskUrls = [...(task || '').matchAll(/https?:\/\/[^\s,)]+/gi)].map(m => m[0]);
291
- if (taskUrls.length > 0 && !webDataBlock) {
292
- sse({ token: '[Fetching task URLs...]' });
352
+ // ── Pre-fetch URLs declared in the task ─────────────────────────
353
+ const taskUrls = extractUrlsFromText(task);
354
+ if (taskUrls.length > 0) {
355
+ sse({ token: `[Fetching ${taskUrls.length} URL(s) from task...]` });
293
356
  const urlFetches = await Promise.all(
294
- taskUrls.slice(0, 3).map(async (url) => {
295
- sse({ token: `[Fetching: ${url}]` });
296
- return { url, content: await runFetchUrl(url) };
357
+ taskUrls.slice(0, 4).map(async (url) => {
358
+ sse({ token: `[GET ${url}]` });
359
+ const r = await runFetchUrl(url);
360
+ fetchOutcomes.attempted++;
361
+ if (r.ok) fetchOutcomes.ok++;
362
+ else { fetchOutcomes.failed++; fetchOutcomes.failures.push({ url: r.url, code: r.code, reason: r.reason }); }
363
+ return r;
297
364
  })
298
365
  );
299
- const urlBlock = urlFetches.filter(f => f.content).map(f => `### Content from ${f.url}:\n${f.content}`).join('\n\n---\n\n');
300
- if (urlBlock) {
301
- webDataBlock = `\n\n## FETCHED WEBSITE CONTENT (THIS IS THE REAL DATA — base your analysis ONLY on this, do NOT invent):\n\n${urlBlock}`;
366
+
367
+ const okFetches = urlFetches.filter(f => f.ok);
368
+ const failedFetches = urlFetches.filter(f => !f.ok);
369
+
370
+ if (okFetches.length > 0) {
371
+ const okBlock = okFetches.map(f => {
372
+ const preamble = f.preamble ? `${f.preamble}\n\n` : '';
373
+ const content = (f.content || '').slice(0, 12_000);
374
+ return `### Content from ${f.url}\n${preamble}${content}`;
375
+ }).join('\n\n---\n\n');
376
+ webDataBlock = `\n\n## FETCHED WEBSITE CONTENT (THIS IS THE GROUND TRUTH — base your analysis EXCLUSIVELY on this. Do NOT invent products, sectors, brands, or facts that are not in this block.):\n\n${okBlock}`;
302
377
  }
378
+
379
+ if (failedFetches.length > 0) {
380
+ const failBlock = failedFetches.map(f => `- ${f.url} → ${f.code}: ${f.reason}`).join('\n');
381
+ webDataBlock += `\n\n## ❌ URL NON RAGGIUNGIBILI (NON inventare contenuto su questi siti — scrivi esplicitamente "fonte non disponibile" nelle sezioni che li riguardano):\n${failBlock}`;
382
+ sse({ data_unavailable: failedFetches.map(f => ({ url: f.url, code: f.code, reason: f.reason })) });
383
+ }
384
+
385
+ // Fail-fast: primary research agent and ALL declared URLs failed → abort.
386
+ if (isPrimaryResearch && okFetches.length === 0 && taskUrls.length > 0) {
387
+ clearInterval(keepalive);
388
+ sse({
389
+ aborted: true,
390
+ reason: 'PRIMARY_RESEARCH_FAILED',
391
+ message: 'Tutti gli URL del task sono irraggiungibili. Pipeline interrotta per evitare contenuto inventato.',
392
+ failures: fetchOutcomes.failures,
393
+ });
394
+ res.write('data: [DONE]\n\n');
395
+ res.end();
396
+ return;
397
+ }
398
+
303
399
  sse({ token: '\n' });
304
400
  }
305
401
 
306
- // Pre-fetch web data BEFORE the LLM call so the model writes with real facts
307
- if (useWebTools && !webDataBlock) {
402
+ // ── Web-search agents: search-driven data collection ────────────
403
+ if (useWebTools) {
308
404
  sse({ token: '[Raccolta dati web...]' });
309
405
 
310
406
  const queries = extractSearchQueries(task, stepDef?.prompt);
@@ -324,7 +420,11 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
324
420
  const fetchResults = await Promise.all(
325
421
  urlMatches.map(async (url) => {
326
422
  sse({ token: `[Fetching: ${url}]` });
327
- return { url, content: await runFetchUrl(url) };
423
+ const r = await runFetchUrl(url);
424
+ fetchOutcomes.attempted++;
425
+ if (r.ok) fetchOutcomes.ok++;
426
+ else fetchOutcomes.failed++;
427
+ return r;
328
428
  })
329
429
  );
330
430
 
@@ -332,11 +432,19 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
332
432
  .map((s) => `### Search: "${s.query}"\n${s.result}`)
333
433
  .join('\n\n---\n\n');
334
434
 
335
- const fetchBlock = fetchResults.length > 0
336
- ? fetchResults.map((f) => `### Full content: ${f.url}\n${f.content}`).join('\n\n---\n\n')
435
+ const fetchBlock = fetchResults
436
+ .filter(f => f.ok)
437
+ .map(f => `### Full content: ${f.url}\n${f.preamble ? f.preamble + '\n\n' : ''}${(f.content || '').slice(0, 8_000)}`)
438
+ .join('\n\n---\n\n');
439
+
440
+ const failedFetches = fetchResults.filter(f => !f.ok);
441
+ const failBlock = failedFetches.length > 0
442
+ ? `\n\n## URL non raggiungibili durante la ricerca:\n${failedFetches.map(f => `- ${f.url} → ${f.code}`).join('\n')}`
337
443
  : '';
338
444
 
339
- webDataBlock = `\n\n## REAL-TIME WEB DATA (use ONLY this data — do NOT invent prices or figures):\n\n${searchBlock}${fetchBlock ? '\n\n---\n\n' + fetchBlock : ''}`;
445
+ const collected = `${searchBlock}${fetchBlock ? '\n\n---\n\n' + fetchBlock : ''}${failBlock}`;
446
+ webDataBlock = (webDataBlock || '') +
447
+ `\n\n## REAL-TIME WEB DATA (use ONLY this data — do NOT invent prices, figures, brands, sectors):\n\n${collected}`;
340
448
 
341
449
  sse({ token: '\n' });
342
450
  }
@@ -355,7 +463,17 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
355
463
  }, { max_tokens: 16384 });
356
464
 
357
465
  clearInterval(keepalive);
358
- sse({ done: true, output, tokensOut });
466
+ // Strip internal think/tool blocks before emitting the final output
467
+ // the streamed tokens may contain them. Quality is computed on the clean
468
+ // text so think-blocks don't inflate output_length artificially.
469
+ const cleanOutput = sanitizeAgentOutput(output);
470
+ const dataQuality = computeDataQuality({
471
+ fetchOutcomes,
472
+ output: cleanOutput,
473
+ taskUrls,
474
+ useWebTools,
475
+ });
476
+ sse({ done: true, output: cleanOutput, tokensOut, dataQuality, fetchOutcomes });
359
477
  res.write('data: [DONE]\n\n');
360
478
  res.end();
361
479
  } catch (e) {
@@ -365,6 +483,30 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
365
483
  }
366
484
  });
367
485
 
486
+ // ── /api/studio/smoke-test — Pre-run reachability check ──────────────
487
+ router.post('/api/studio/smoke-test', async (req, res) => {
488
+ try {
489
+ const body = await parseBody(req);
490
+ let urls = Array.isArray(body.urls) ? body.urls : [];
491
+ if (!urls.length && typeof body.task === 'string') urls = extractUrlsFromText(body.task);
492
+ if (!urls.length) return sendJSON(res, 200, { probes: [], allOk: true });
493
+
494
+ const probes = await Promise.all(
495
+ urls.slice(0, 8).map(async (url) => {
496
+ try {
497
+ const r = await headProbe(url);
498
+ return { url, ok: r.ok, status: r.status, reason: r.reason || '', finalUrl: r.finalUrl };
499
+ } catch (e) {
500
+ return { url, ok: false, status: 0, reason: e.message || String(e) };
501
+ }
502
+ })
503
+ );
504
+
505
+ const allOk = probes.every(p => p.ok);
506
+ sendJSON(res, 200, { probes, allOk });
507
+ } catch (e) { sendError(res, 500, e.message); }
508
+ });
509
+
368
510
  // ── /api/studio/deliberate — Parliament Geth Consensus ───────────────
369
511
  router.post('/api/studio/deliberate', async (req, res) => {
370
512
  const body = await parseBody(req);
@@ -470,6 +612,66 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
470
612
  });
471
613
  }
472
614
 
615
+ // ── Data-quality scoring (replaces fake sentiment "confidence") ────────
616
+ /**
617
+ * Compute a 0..1 data-quality score from observable signals.
618
+ *
619
+ * Signals (weighted):
620
+ * - fetch_success_ratio: fraction of attempted fetches that succeeded (0.45)
621
+ * - declared_urls_ok: primary URLs from the task were reachable (0.25)
622
+ * - output_length: produced a substantive response (>= 600 chars) (0.10)
623
+ * - no_error_strings: output free of "fetch failed", "search failed" (0.10)
624
+ * - structured_evidence: output cites numbers, dates, or quoted strings (0.10)
625
+ *
626
+ * Returns { score: number, label: 'high'|'medium'|'low', signals: {...} }.
627
+ */
628
+ export function computeDataQuality({ fetchOutcomes, output = '', taskUrls = [], useWebTools = false }) {
629
+ const out = String(output || '');
630
+ const signals = {};
631
+
632
+ const totalAttempted = fetchOutcomes?.attempted || 0;
633
+ const totalOk = fetchOutcomes?.ok || 0;
634
+ if (totalAttempted > 0) {
635
+ signals.fetch_success_ratio = totalOk / totalAttempted;
636
+ } else if (useWebTools || taskUrls.length > 0) {
637
+ signals.fetch_success_ratio = 0;
638
+ } else {
639
+ signals.fetch_success_ratio = 1; // No fetches expected → neutral
640
+ }
641
+
642
+ const declaredFailures = (fetchOutcomes?.failures || []).filter(f => taskUrls.includes(f.url)).length;
643
+ if (taskUrls.length === 0) {
644
+ signals.declared_urls_ok = 1;
645
+ } else {
646
+ signals.declared_urls_ok = Math.max(0, 1 - declaredFailures / taskUrls.length);
647
+ }
648
+
649
+ signals.output_length = Math.min(1, out.length / 1_200);
650
+
651
+ const errorMarkers = /fetch failed|search failed|errore 500|errore 404|http_500|http_404|http_429|http_4\d{2}|timeout|network error/gi;
652
+ const errorCount = (out.match(errorMarkers) || []).length;
653
+ signals.no_error_strings = Math.max(0, 1 - errorCount / 5);
654
+
655
+ const hasNumbers = /\b\d{1,4}(?:[.,]\d+)?(?:\s*(%|€|\$|£|¥|kg|cm|mm|km|°c))?/i.test(out);
656
+ const hasDates = /\b(19|20)\d{2}\b|\b\d{1,2}[\/\-.]\d{1,2}[\/\-.](19|20)?\d{2}\b/.test(out);
657
+ const hasQuotes = /"[^"]{8,}"|"[^"]{8,}"/.test(out);
658
+ const structScore = (hasNumbers ? 0.5 : 0) + (hasDates ? 0.25 : 0) + (hasQuotes ? 0.25 : 0);
659
+ signals.structured_evidence = Math.min(1, structScore);
660
+
661
+ const score =
662
+ 0.45 * signals.fetch_success_ratio +
663
+ 0.25 * signals.declared_urls_ok +
664
+ 0.10 * signals.output_length +
665
+ 0.10 * signals.no_error_strings +
666
+ 0.10 * signals.structured_evidence;
667
+
668
+ let label = 'low';
669
+ if (score >= 0.70) label = 'high';
670
+ else if (score >= 0.50) label = 'medium';
671
+
672
+ return { score: Math.round(score * 100) / 100, label, signals };
673
+ }
674
+
473
675
  // ── Keyword fallback planner (used when LLM is unavailable) ────────────
474
676
 
475
677
  function buildKeywordFallback(task, sanitizedTask, hasPdf, pdfName, it) {
@@ -2029,15 +2029,45 @@ export async function executeTool(action, params, config) {
2029
2029
  const url = params.url;
2030
2030
  if (!url) return 'A URL is required.';
2031
2031
 
2032
- const result = await wt.fetchUrl(url);
2033
- if (result.error) return `Fetch error: ${result.message}`;
2032
+ // Use the rich variant so we get OpenGraph / JSON-LD / headings.
2033
+ // The structured preamble dramatically reduces hallucination on sites
2034
+ // where the body alone is ambiguous (e.g. SPA shells, B2B catalogs).
2035
+ const result = await wt.fetchUrlRich(url);
2036
+ if (result.error) {
2037
+ return `Fetch error (${result.code || 'UNKNOWN'}): ${result.message}. ` +
2038
+ `Try web_search to find an alternative source, or browser_open for JS-rendered pages.`;
2039
+ }
2034
2040
 
2041
+ const meta = result.metadata || {};
2035
2042
  const lines = [];
2036
2043
  if (result.title) lines.push(`Title: ${result.title}`);
2037
2044
  lines.push(`URL: ${result.url || url}`);
2038
- lines.push(`Status: ${result.status}`);
2045
+ lines.push(`Status: ${result.status} Bytes: ${result.bytes} Attempts: ${result.attempts}`);
2046
+ if (meta.description) lines.push(`Description: ${meta.description}`);
2047
+ if (meta.lang) lines.push(`Lang: ${meta.lang}`);
2048
+ if (meta.canonical) lines.push(`Canonical: ${meta.canonical}`);
2049
+
2050
+ const ogKeys = Object.keys(meta.og || {});
2051
+ if (ogKeys.length) {
2052
+ const ogPairs = ogKeys.slice(0, 8).map(k => `${k}=${String(meta.og[k]).slice(0, 240)}`);
2053
+ lines.push(`OpenGraph: ${ogPairs.join(' | ')}`);
2054
+ }
2055
+
2056
+ const ldTypes = (meta.jsonLd || []).map(j => j.type).filter(Boolean);
2057
+ if (ldTypes.length) {
2058
+ lines.push(`Schema.org types found: ${[...new Set(ldTypes)].join(', ')}`);
2059
+ const ldNames = (meta.jsonLd || []).map(j => j.name).filter(Boolean).slice(0, 5);
2060
+ if (ldNames.length) lines.push(`Schema.org entities: ${ldNames.join(' | ')}`);
2061
+ }
2062
+
2063
+ if (meta.headings) {
2064
+ if (meta.headings.h1?.length) lines.push(`H1: ${meta.headings.h1.slice(0, 3).join(' | ')}`);
2065
+ if (meta.headings.h2?.length) lines.push(`H2: ${meta.headings.h2.slice(0, 6).join(' | ')}`);
2066
+ }
2067
+
2039
2068
  if (result.truncated) lines.push('[Content was truncated due to size limits]');
2040
2069
  lines.push('');
2070
+ lines.push('--- MAIN CONTENT ---');
2041
2071
  lines.push(result.body);
2042
2072
 
2043
2073
  return lines.join('\n');
@@ -2047,13 +2077,37 @@ export async function executeTool(action, params, config) {
2047
2077
  case 'get_weather': {
2048
2078
  const location = (params.location || '').trim();
2049
2079
  if (!location) return 'A location is required (e.g. "Rome", "Viterbo, Italy").';
2080
+ const encodedLoc = encodeURIComponent(location);
2081
+ const WTTR_UA = 'Mozilla/5.0 (compatible; nha-weather/1.0; +https://nothumanallowed.com)';
2082
+ const fetchWithRetry = async (attempts = 3) => {
2083
+ let lastErr = null;
2084
+ for (let i = 0; i < attempts; i++) {
2085
+ try {
2086
+ const r = await fetch(`https://wttr.in/${encodedLoc}?format=j1`, {
2087
+ headers: { 'User-Agent': WTTR_UA, 'Accept': 'application/json' },
2088
+ signal: AbortSignal.timeout(10_000),
2089
+ });
2090
+ if (r.ok) return r;
2091
+ // 429/5xx → retry; 4xx other → don't retry
2092
+ if (r.status === 429 || (r.status >= 500 && r.status < 600)) {
2093
+ await new Promise(rs => setTimeout(rs, 600 * (i + 1) + Math.random() * 400));
2094
+ continue;
2095
+ }
2096
+ return r;
2097
+ } catch (e) {
2098
+ lastErr = e;
2099
+ if (i < attempts - 1) await new Promise(rs => setTimeout(rs, 600 * (i + 1)));
2100
+ }
2101
+ }
2102
+ if (lastErr) throw lastErr;
2103
+ return null;
2104
+ };
2050
2105
  try {
2051
- const encodedLoc = encodeURIComponent(location);
2052
- const wttrRes = await fetch(`https://wttr.in/${encodedLoc}?format=j1`, {
2053
- headers: { 'User-Agent': 'nha-cli/1.0' },
2054
- signal: AbortSignal.timeout(8000),
2055
- });
2056
- if (!wttrRes.ok) return `Weather service returned ${wttrRes.status} for "${location}". Try a different location name.`;
2106
+ const wttrRes = await fetchWithRetry(3);
2107
+ if (!wttrRes || !wttrRes.ok) {
2108
+ const status = wttrRes ? wttrRes.status : 'NET_ERR';
2109
+ return `Weather service returned ${status} for "${location}". Try a more specific location (e.g. "Rome, Italy") or use web_search("weather ${location}") as fallback.`;
2110
+ }
2057
2111
  const w = await wttrRes.json();
2058
2112
  const cur = w.current_condition?.[0];
2059
2113
  const area = w.nearest_area?.[0];