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 +1 -1
- package/src/constants.mjs +1 -1
- package/src/server/routes/studio.mjs +432 -32
- package/src/services/tool-executor.mjs +63 -9
- package/src/services/web-tools.mjs +611 -284
- package/src/ui-dist/assets/{index-B9Bd4Mrx.js → index-1ka48YcO.js} +92 -68
- package/src/ui-dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
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.
|
|
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
|
-
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
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
|
|
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
|
|
102
|
-
if (result.error)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
290
|
-
const taskUrls =
|
|
291
|
-
if (taskUrls.length > 0
|
|
292
|
-
sse({ token:
|
|
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,
|
|
295
|
-
sse({ token: `[
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
307
|
-
if (useWebTools
|
|
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
|
-
|
|
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
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2033
|
-
|
|
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
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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];
|