nothumanallowed 15.1.4 → 15.1.6

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.6",
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.6';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -9,14 +9,45 @@ 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
- // Agents that get web data pre-fetched before their LLM call
14
+ // Agents that get web data pre-fetched (search + URL content) before their LLM call.
15
+ // We err on the side of inclusion: any agent that writes analysis, copy, briefings,
16
+ // or reports benefits from real-time data — without it they hallucinate plausible
17
+ // but generic frameworks. Synthesis-only agents (CanvasAgent, GitHub/Email/Slack
18
+ // connectors) are intentionally excluded.
15
19
  const WEB_TOOL_AGENTS = new Set([
16
- 'WebSearchAgent', 'TravelAgent', 'mercury', 'MERCURY',
17
- 'athena', 'ATHENA', 'oracle', 'ORACLE', 'cassandra', 'CASSANDRA',
18
- 'HERALD', 'DataAnalystAgent', 'herald', 'tempest', 'TEMPEST',
19
- 'epicure', 'EPICURE', 'cartographer', 'CARTOGRAPHER',
20
+ // Original research/intel set
21
+ 'WebSearchAgent', 'TravelAgent',
22
+ 'MERCURY', 'mercury',
23
+ 'ATHENA', 'athena',
24
+ 'ORACLE', 'oracle',
25
+ 'CASSANDRA', 'cassandra',
26
+ 'HERALD', 'herald',
27
+ 'DataAnalystAgent',
28
+ 'TEMPEST', 'tempest',
29
+ 'EPICURE', 'epicure',
30
+ 'CARTOGRAPHER', 'cartographer',
31
+ // Analysis & writing — they MUST ground on the target site, not invent.
32
+ 'QUILL', 'quill',
33
+ 'ECHO', 'echo',
34
+ 'MURASAKI', 'murasaki',
35
+ 'SCHEHERAZADE', 'scheherazade',
36
+ 'MUSE', 'muse',
37
+ // Security / architecture / reasoning — also benefit from grounded context.
38
+ 'FORGE', 'forge',
39
+ 'PROMETHEUS', 'prometheus',
40
+ 'SABER', 'saber',
41
+ 'ADE', 'ade',
42
+ 'ZERO', 'zero',
43
+ 'SAURON', 'sauron',
44
+ 'LOGOS', 'logos',
45
+ 'VERITAS', 'veritas',
46
+ 'LINK', 'link',
47
+ 'NAVI', 'navi',
48
+ 'EDI', 'edi',
49
+ // Polyglot needs source content to translate accurately.
50
+ 'polyglot',
20
51
  ]);
21
52
 
22
53
  // ── Complete Agent Registry ─────────────────────────────────────────────────
@@ -94,20 +125,197 @@ async function runWebSearch(query) {
94
125
  }
95
126
 
96
127
  /**
97
- * Fetch a URL and return formatted content string.
128
+ * Fetch a URL and return a structured result. Caller decides how to format it.
129
+ * Returns { ok, url, content?, title?, metadata?, code?, reason? }.
98
130
  */
99
131
  async function runFetchUrl(url) {
100
132
  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}`;
133
+ const result = await fetchUrlRich(url, { maxChars: 16_000 });
134
+ if (result.error) {
135
+ return { ok: false, url, code: result.code || 'FETCH_FAILED', reason: result.message || 'unknown' };
136
+ }
137
+ const meta = result.metadata || {};
138
+ const ogBlock = Object.keys(meta.og || {}).length
139
+ ? `OpenGraph: ${Object.entries(meta.og).slice(0, 8).map(([k, v]) => `${k}=${String(v).slice(0, 200)}`).join(' | ')}\n`
140
+ : '';
141
+ const ldBlock = (meta.jsonLd || []).length
142
+ ? `Schema.org types: ${meta.jsonLd.slice(0, 6).map(j => j.type).join(', ')}\n`
143
+ : '';
144
+ const headingsBlock = meta.headings && (meta.headings.h1?.length || meta.headings.h2?.length)
145
+ ? `Headings H1: ${(meta.headings.h1 || []).slice(0, 5).join(' | ')}\n` +
146
+ (meta.headings.h2?.length ? `Headings H2: ${meta.headings.h2.slice(0, 8).join(' | ')}\n` : '')
147
+ : '';
148
+ const descBlock = meta.description ? `Description: ${meta.description}\n` : '';
149
+ return {
150
+ ok: true,
151
+ url: result.url || url,
152
+ title: result.title || '',
153
+ content: result.body || '',
154
+ metadata: meta,
155
+ preamble: `${result.title ? `Title: ${result.title}\n` : ''}${descBlock}${ogBlock}${ldBlock}${headingsBlock}`.trim(),
156
+ };
106
157
  } catch (e) {
107
- return `Fetch error: ${e.message}`;
158
+ return { ok: false, url, code: 'EXCEPTION', reason: e.message || String(e) };
108
159
  }
109
160
  }
110
161
 
162
+ /**
163
+ * Extract URLs from free-form text — robust matcher.
164
+ *
165
+ * Catches:
166
+ * - Fully-qualified URLs: http(s)://example.com/path
167
+ * - Bare domains with www: www.example.com
168
+ * - Bare domains without www: example.com, example.it, example.co.uk
169
+ * - Markdown-wrapped: [label](example.com) — the URL portion is matched
170
+ *
171
+ * Rejects:
172
+ * - File extensions misread as domains: image.png, doc.pdf, app.js
173
+ * - Single-word tokens without a dot
174
+ * - Email addresses (the @ rules out the domain match)
175
+ *
176
+ * Returns normalized https:// URLs, deduplicated, max 6.
177
+ */
178
+ const FILE_EXT_BLOCKLIST = new Set([
179
+ 'png','jpg','jpeg','gif','webp','svg','ico','bmp','tiff',
180
+ 'pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','zip','tar','gz','rar','7z',
181
+ 'mp3','mp4','wav','ogg','mov','avi','mkv',
182
+ 'js','css','json','xml','yaml','yml','md','html','htm',
183
+ ]);
184
+
185
+ function extractUrlsFromText(text) {
186
+ if (!text) return [];
187
+ const t = String(text);
188
+ const found = new Set();
189
+
190
+ // Full URLs
191
+ for (const m of t.matchAll(/https?:\/\/[^\s,)<>"'`]+/gi)) {
192
+ const clean = m[0].replace(/[.,;:!?)\]]+$/, '');
193
+ found.add(clean);
194
+ }
195
+
196
+ // Bare domains — TLD of 2..24 letters. Accepts 2-label (apple.com),
197
+ // 3-label (www.apple.com), and N-label domains.
198
+ const bareRe = /(?<![\/@\w])(?:www\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.([a-z]{2,24})\b(?:\/[^\s,()<>"'`]*)?/gi;
199
+ for (const m of t.matchAll(bareRe)) {
200
+ const candidate = m[0].replace(/[.,;:!?)\]]+$/, '');
201
+ if (candidate.startsWith('http')) continue;
202
+ const tld = (m[1] || '').toLowerCase();
203
+ if (FILE_EXT_BLOCKLIST.has(tld)) continue;
204
+ if (/^\d+(\.\d+)+$/.test(candidate)) continue;
205
+ found.add('https://' + candidate);
206
+ }
207
+
208
+ // Dedup: if "https://x.com/path" and "https://www.x.com/path" both present,
209
+ // keep the longer (more specific) one only.
210
+ const list = [...found];
211
+ const filtered = list.filter(url => {
212
+ const stripped = url.replace(/^https?:\/\/(www\.)?/, '');
213
+ return !list.some(other =>
214
+ other !== url &&
215
+ other.replace(/^https?:\/\/(www\.)?/, '') === stripped &&
216
+ other.length > url.length,
217
+ );
218
+ });
219
+
220
+ return filtered.slice(0, 6);
221
+ }
222
+
223
+ /**
224
+ * Automatic fallback: when the primary fetch fails, try to gather indirect
225
+ * intelligence about the domain via web search. Looks for:
226
+ * - site:domain.com listings (whatever Google has indexed)
227
+ * - "<domain> chi è" / "<domain> azienda settore" — public profiles, news
228
+ * - Wikipedia / corporate registries
229
+ *
230
+ * Returns up to 3 fetched pages with real content, formatted for ground truth.
231
+ * Returns null if nothing useful was found.
232
+ */
233
+ async function gatherIndirectIntel(failedUrl, sse) {
234
+ try {
235
+ const parsed = new URL(failedUrl);
236
+ const domain = parsed.hostname.replace(/^www\./, '');
237
+ const queries = [
238
+ `site:${domain}`,
239
+ `"${domain}" azienda settore prodotti`,
240
+ `${domain} chi è cosa fa`,
241
+ ];
242
+
243
+ if (sse) sse({ token: `[Fallback: ricerco info indirette su ${domain}...]` });
244
+
245
+ const allResults = [];
246
+ for (const q of queries) {
247
+ try {
248
+ const r = await webSearch(q, 5);
249
+ if (!r.error && Array.isArray(r.results)) {
250
+ for (const item of r.results) {
251
+ // Skip if the URL is the same failed domain (we already know it's down)
252
+ try {
253
+ const u = new URL(item.url);
254
+ if (u.hostname.replace(/^www\./, '') === domain) continue;
255
+ } catch { /* skip invalid URLs */ }
256
+ allResults.push({ ...item, fromQuery: q });
257
+ }
258
+ }
259
+ } catch { /* skip failed query */ }
260
+ }
261
+
262
+ if (allResults.length === 0) return null;
263
+
264
+ // Dedupe by URL, take top 4, fetch them
265
+ const seen = new Set();
266
+ const unique = allResults.filter(r => {
267
+ if (seen.has(r.url)) return false;
268
+ seen.add(r.url);
269
+ return true;
270
+ }).slice(0, 4);
271
+
272
+ if (sse) sse({ token: `[Fallback: ${unique.length} fonti indirette trovate, recupero contenuto...]` });
273
+
274
+ const fetched = await Promise.all(
275
+ unique.map(async (r) => {
276
+ try {
277
+ const full = await fetchUrlRich(r.url, { maxChars: 4_000, timeout: 8_000 });
278
+ if (full.error) return null;
279
+ return {
280
+ url: r.url,
281
+ title: full.title || r.title || '',
282
+ snippet: r.snippet || '',
283
+ content: (full.body || '').slice(0, 3_000),
284
+ description: full.metadata?.description || '',
285
+ fromQuery: r.fromQuery,
286
+ };
287
+ } catch { return null; }
288
+ })
289
+ );
290
+
291
+ const good = fetched.filter(Boolean);
292
+ if (good.length === 0) return null;
293
+
294
+ return good;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Strip transient status markers, internal think/tool blocks, and propagation
302
+ * noise before the text is injected into another agent's prompt or surfaced
303
+ * to the client. Parity with LegionX `CommunicationStream.stripTags`.
304
+ */
305
+ function sanitizeAgentOutput(text) {
306
+ if (!text) return '';
307
+ return String(text)
308
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
309
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
310
+ .replace(/<tool[^>]*>[\s\S]*?<\/tool>/gi, '')
311
+ .replace(/<scratch[^>]*>[\s\S]*?<\/scratch>/gi, '')
312
+ .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
313
+ .replace(/\[(?:Searching|Fetching|GET|POST|HEAD|Raccolta dati web|Parlamento)[^\]]*\]\s*/gi, '')
314
+ .replace(/​|/g, '') // zero-width / BOM
315
+ .replace(/\r\n/g, '\n')
316
+ .trim();
317
+ }
318
+
111
319
  /**
112
320
  * Extract search queries from a task string.
113
321
  * Returns up to 3 queries covering different angles.
@@ -257,8 +465,24 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
257
465
  } catch {}
258
466
  }
259
467
 
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)}` : '';
468
+ const contextBlock = context ? `\n\n## CONTEXT FROM PREVIOUS STEPS:\n${sanitizeAgentOutput(context).slice(0, 12000)}` : '';
469
+
470
+ // Cross-reading block — when other agents have already produced proposals,
471
+ // we inject them with explicit deliberation instructions so the model
472
+ // engages with them (agree/disagree) rather than restating its own view.
473
+ const proposalContextBlock = body.proposalContext ? `
474
+
475
+ ## ALTRE PROPOSTE GIÀ COMPLETATE (CROSS-READING)
476
+
477
+ I seguenti output sono proposte di altri agenti che hanno lavorato sullo stesso task. Leggile attentamente prima di rispondere.
478
+
479
+ ${sanitizeAgentOutput(body.proposalContext).slice(0, 12000)}
480
+
481
+ ### ISTRUZIONI DI DELIBERAZIONE (obbligatorie)
482
+ 1. **Identifica i punti in cui CONCORDI** con un agente precedente → marca con \`[✓ CONCORDA CON: NomeAgente]\` e cita il punto specifico.
483
+ 2. **Identifica i punti in cui DISSENTI** → marca con \`[✗ DISACCORDO CON: NomeAgente]\` e spiega il motivo con evidenze concrete (dai dati fetched, non opinioni).
484
+ 3. **NON ripetere ciò che hanno già detto bene** — aggiungi il TUO contributo specifico dalla TUA specializzazione.
485
+ 4. **Se i dati raccolti contraddicono un agente precedente**, segnalalo esplicitamente.` : '';
262
486
 
263
487
  const formatInstructions = `\n\nFORMATTING RULES (CRITICAL — your output will be rendered as HTML):
264
488
  - Use MARKDOWN TABLES with | pipes | for ALL tabular data. Example:
@@ -284,27 +508,97 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
284
508
  const userMessage = stepDef?.prompt || task;
285
509
 
286
510
  const useWebTools = WEB_TOOL_AGENTS.has(agent);
511
+ const isPrimaryResearch = !!body.isPrimaryResearch || !!stepDef?.isPrimaryResearch;
287
512
  let webDataBlock = '';
513
+ const fetchOutcomes = { ok: 0, failed: 0, attempted: 0, failures: [] };
288
514
 
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...]' });
515
+ // ── Pre-fetch URLs declared in the task ─────────────────────────
516
+ const taskUrls = extractUrlsFromText(task);
517
+ if (taskUrls.length > 0) {
518
+ sse({ token: `[Fetching ${taskUrls.length} URL(s) from task...]` });
293
519
  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) };
520
+ taskUrls.slice(0, 4).map(async (url) => {
521
+ sse({ token: `[GET ${url}]` });
522
+ const r = await runFetchUrl(url);
523
+ fetchOutcomes.attempted++;
524
+ if (r.ok) fetchOutcomes.ok++;
525
+ else { fetchOutcomes.failed++; fetchOutcomes.failures.push({ url: r.url, code: r.code, reason: r.reason }); }
526
+ return r;
297
527
  })
298
528
  );
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}`;
529
+
530
+ const okFetches = urlFetches.filter(f => f.ok);
531
+ const failedFetches = urlFetches.filter(f => !f.ok);
532
+
533
+ if (okFetches.length > 0) {
534
+ const okBlock = okFetches.map(f => {
535
+ const preamble = f.preamble ? `${f.preamble}\n\n` : '';
536
+ const content = (f.content || '').slice(0, 12_000);
537
+ return `### Content from ${f.url}\n${preamble}${content}`;
538
+ }).join('\n\n---\n\n');
539
+ webDataBlock = `
540
+
541
+ ## ✅ DATI REALI RACCOLTI DAL SITO (GROUND TRUTH — usa SOLO questi)
542
+
543
+ Quanto segue è il contenuto effettivamente estratto dai siti specificati nel task.
544
+ È OBBLIGATORIO basare l'analisi su questi dati e SOLO su questi. È VIETATO:
545
+ - Inventare il settore, i prodotti, il target o il modello di business.
546
+ - Aggiungere "esempi tipici del settore" o "best practice generiche" senza riferirsi ai dati sopra.
547
+ - Scrivere "da verificare" o "supponiamo che" quando il dato è già presente nel blocco.
548
+
549
+ ${okBlock}`;
302
550
  }
551
+
552
+ // ── Automatic indirect-intel fallback ──
553
+ // Search the web for info ABOUT each failed domain (registries, news,
554
+ // Wikipedia, profiles). This gives the agent grounded data even when
555
+ // the primary site is down, instead of forcing it to invent a framework.
556
+ let recoveredAny = false;
557
+ if (failedFetches.length > 0) {
558
+ const failBlock = failedFetches.map(f => `- ${f.url} → ${f.code}: ${f.reason}`).join('\n');
559
+ webDataBlock += `\n\n## ⚠ FONTI PRIMARIE NON DISPONIBILI\n${failBlock}`;
560
+ sse({ data_unavailable: failedFetches.map(f => ({ url: f.url, code: f.code, reason: f.reason })) });
561
+
562
+ const indirectBlocks = [];
563
+ for (const f of failedFetches.slice(0, 3)) {
564
+ const intel = await gatherIndirectIntel(f.url, sse);
565
+ if (intel && intel.length > 0) {
566
+ recoveredAny = true;
567
+ const block = intel.map(i =>
568
+ `### ${i.title || '(senza titolo)'}\nURL: ${i.url}\nQuery: "${i.fromQuery}"\n${i.description ? `Descrizione: ${i.description}\n` : ''}\n${i.content}`,
569
+ ).join('\n\n---\n\n');
570
+ indirectBlocks.push(`#### Info indirette su ${f.url}:\n\n${block}`);
571
+ }
572
+ }
573
+
574
+ if (indirectBlocks.length > 0) {
575
+ webDataBlock += `\n\n## 🔎 FONTI INDIRETTE (raccolte automaticamente perché le fonti primarie sono down)\n\nIl sito principale non è raggiungibile, ma queste fonti pubbliche parlano dello stesso dominio/azienda. **Usa queste informazioni come ground truth** — sono dati reali, non inventati.\n\n${indirectBlocks.join('\n\n---\n\n')}`;
576
+ sse({ token: '[Fallback: recupero indiretto riuscito, procedo con i dati alternativi.]' });
577
+ } else {
578
+ webDataBlock += `\n\nNessuna fonte indiretta utile trovata sul web per i domini sopra. Per le sezioni che riguardano questi URL, scrivi esplicitamente "Fonte non raggiungibile — analisi non eseguibile". NON sostituire con framework generici, esempi tipici, o supposizioni basate sul nome del dominio.`;
579
+ }
580
+ }
581
+
582
+ // Fail-fast: only abort if primary research has NO data AT ALL —
583
+ // neither primary fetches nor indirect intel recovered anything.
584
+ if (isPrimaryResearch && okFetches.length === 0 && !recoveredAny && taskUrls.length > 0) {
585
+ clearInterval(keepalive);
586
+ sse({
587
+ aborted: true,
588
+ reason: 'PRIMARY_RESEARCH_FAILED',
589
+ message: 'Tutti gli URL del task sono irraggiungibili. Pipeline interrotta per evitare contenuto inventato.',
590
+ failures: fetchOutcomes.failures,
591
+ });
592
+ res.write('data: [DONE]\n\n');
593
+ res.end();
594
+ return;
595
+ }
596
+
303
597
  sse({ token: '\n' });
304
598
  }
305
599
 
306
- // Pre-fetch web data BEFORE the LLM call so the model writes with real facts
307
- if (useWebTools && !webDataBlock) {
600
+ // ── Web-search agents: search-driven data collection ────────────
601
+ if (useWebTools) {
308
602
  sse({ token: '[Raccolta dati web...]' });
309
603
 
310
604
  const queries = extractSearchQueries(task, stepDef?.prompt);
@@ -324,7 +618,11 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
324
618
  const fetchResults = await Promise.all(
325
619
  urlMatches.map(async (url) => {
326
620
  sse({ token: `[Fetching: ${url}]` });
327
- return { url, content: await runFetchUrl(url) };
621
+ const r = await runFetchUrl(url);
622
+ fetchOutcomes.attempted++;
623
+ if (r.ok) fetchOutcomes.ok++;
624
+ else fetchOutcomes.failed++;
625
+ return r;
328
626
  })
329
627
  );
330
628
 
@@ -332,11 +630,19 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
332
630
  .map((s) => `### Search: "${s.query}"\n${s.result}`)
333
631
  .join('\n\n---\n\n');
334
632
 
335
- const fetchBlock = fetchResults.length > 0
336
- ? fetchResults.map((f) => `### Full content: ${f.url}\n${f.content}`).join('\n\n---\n\n')
633
+ const fetchBlock = fetchResults
634
+ .filter(f => f.ok)
635
+ .map(f => `### Full content: ${f.url}\n${f.preamble ? f.preamble + '\n\n' : ''}${(f.content || '').slice(0, 8_000)}`)
636
+ .join('\n\n---\n\n');
637
+
638
+ const failedFetches = fetchResults.filter(f => !f.ok);
639
+ const failBlock = failedFetches.length > 0
640
+ ? `\n\n## URL non raggiungibili durante la ricerca:\n${failedFetches.map(f => `- ${f.url} → ${f.code}`).join('\n')}`
337
641
  : '';
338
642
 
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 : ''}`;
643
+ const collected = `${searchBlock}${fetchBlock ? '\n\n---\n\n' + fetchBlock : ''}${failBlock}`;
644
+ webDataBlock = (webDataBlock || '') +
645
+ `\n\n## REAL-TIME WEB DATA (use ONLY this data — do NOT invent prices, figures, brands, sectors):\n\n${collected}`;
340
646
 
341
647
  sse({ token: '\n' });
342
648
  }
@@ -355,7 +661,17 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
355
661
  }, { max_tokens: 16384 });
356
662
 
357
663
  clearInterval(keepalive);
358
- sse({ done: true, output, tokensOut });
664
+ // Strip internal think/tool blocks before emitting the final output
665
+ // the streamed tokens may contain them. Quality is computed on the clean
666
+ // text so think-blocks don't inflate output_length artificially.
667
+ const cleanOutput = sanitizeAgentOutput(output);
668
+ const dataQuality = computeDataQuality({
669
+ fetchOutcomes,
670
+ output: cleanOutput,
671
+ taskUrls,
672
+ useWebTools,
673
+ });
674
+ sse({ done: true, output: cleanOutput, tokensOut, dataQuality, fetchOutcomes });
359
675
  res.write('data: [DONE]\n\n');
360
676
  res.end();
361
677
  } catch (e) {
@@ -365,6 +681,30 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
365
681
  }
366
682
  });
367
683
 
684
+ // ── /api/studio/smoke-test — Pre-run reachability check ──────────────
685
+ router.post('/api/studio/smoke-test', async (req, res) => {
686
+ try {
687
+ const body = await parseBody(req);
688
+ let urls = Array.isArray(body.urls) ? body.urls : [];
689
+ if (!urls.length && typeof body.task === 'string') urls = extractUrlsFromText(body.task);
690
+ if (!urls.length) return sendJSON(res, 200, { probes: [], allOk: true });
691
+
692
+ const probes = await Promise.all(
693
+ urls.slice(0, 8).map(async (url) => {
694
+ try {
695
+ const r = await headProbe(url);
696
+ return { url, ok: r.ok, status: r.status, reason: r.reason || '', finalUrl: r.finalUrl };
697
+ } catch (e) {
698
+ return { url, ok: false, status: 0, reason: e.message || String(e) };
699
+ }
700
+ })
701
+ );
702
+
703
+ const allOk = probes.every(p => p.ok);
704
+ sendJSON(res, 200, { probes, allOk });
705
+ } catch (e) { sendError(res, 500, e.message); }
706
+ });
707
+
368
708
  // ── /api/studio/deliberate — Parliament Geth Consensus ───────────────
369
709
  router.post('/api/studio/deliberate', async (req, res) => {
370
710
  const body = await parseBody(req);
@@ -470,6 +810,66 @@ Select the optimal agent pipeline. Output ONLY the JSON.`;
470
810
  });
471
811
  }
472
812
 
813
+ // ── Data-quality scoring (replaces fake sentiment "confidence") ────────
814
+ /**
815
+ * Compute a 0..1 data-quality score from observable signals.
816
+ *
817
+ * Signals (weighted):
818
+ * - fetch_success_ratio: fraction of attempted fetches that succeeded (0.45)
819
+ * - declared_urls_ok: primary URLs from the task were reachable (0.25)
820
+ * - output_length: produced a substantive response (>= 600 chars) (0.10)
821
+ * - no_error_strings: output free of "fetch failed", "search failed" (0.10)
822
+ * - structured_evidence: output cites numbers, dates, or quoted strings (0.10)
823
+ *
824
+ * Returns { score: number, label: 'high'|'medium'|'low', signals: {...} }.
825
+ */
826
+ export function computeDataQuality({ fetchOutcomes, output = '', taskUrls = [], useWebTools = false }) {
827
+ const out = String(output || '');
828
+ const signals = {};
829
+
830
+ const totalAttempted = fetchOutcomes?.attempted || 0;
831
+ const totalOk = fetchOutcomes?.ok || 0;
832
+ if (totalAttempted > 0) {
833
+ signals.fetch_success_ratio = totalOk / totalAttempted;
834
+ } else if (useWebTools || taskUrls.length > 0) {
835
+ signals.fetch_success_ratio = 0;
836
+ } else {
837
+ signals.fetch_success_ratio = 1; // No fetches expected → neutral
838
+ }
839
+
840
+ const declaredFailures = (fetchOutcomes?.failures || []).filter(f => taskUrls.includes(f.url)).length;
841
+ if (taskUrls.length === 0) {
842
+ signals.declared_urls_ok = 1;
843
+ } else {
844
+ signals.declared_urls_ok = Math.max(0, 1 - declaredFailures / taskUrls.length);
845
+ }
846
+
847
+ signals.output_length = Math.min(1, out.length / 1_200);
848
+
849
+ const errorMarkers = /fetch failed|search failed|errore 500|errore 404|http_500|http_404|http_429|http_4\d{2}|timeout|network error/gi;
850
+ const errorCount = (out.match(errorMarkers) || []).length;
851
+ signals.no_error_strings = Math.max(0, 1 - errorCount / 5);
852
+
853
+ const hasNumbers = /\b\d{1,4}(?:[.,]\d+)?(?:\s*(%|€|\$|£|¥|kg|cm|mm|km|°c))?/i.test(out);
854
+ const hasDates = /\b(19|20)\d{2}\b|\b\d{1,2}[\/\-.]\d{1,2}[\/\-.](19|20)?\d{2}\b/.test(out);
855
+ const hasQuotes = /"[^"]{8,}"|"[^"]{8,}"/.test(out);
856
+ const structScore = (hasNumbers ? 0.5 : 0) + (hasDates ? 0.25 : 0) + (hasQuotes ? 0.25 : 0);
857
+ signals.structured_evidence = Math.min(1, structScore);
858
+
859
+ const score =
860
+ 0.45 * signals.fetch_success_ratio +
861
+ 0.25 * signals.declared_urls_ok +
862
+ 0.10 * signals.output_length +
863
+ 0.10 * signals.no_error_strings +
864
+ 0.10 * signals.structured_evidence;
865
+
866
+ let label = 'low';
867
+ if (score >= 0.70) label = 'high';
868
+ else if (score >= 0.50) label = 'medium';
869
+
870
+ return { score: Math.round(score * 100) / 100, label, signals };
871
+ }
872
+
473
873
  // ── Keyword fallback planner (used when LLM is unavailable) ────────────
474
874
 
475
875
  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];