nothumanallowed 12.7.0 → 13.2.13

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.
@@ -27,7 +27,7 @@ import {
27
27
  } from '../services/task-store.mjs';
28
28
  import { runPlanningPipeline } from '../services/ops-pipeline.mjs';
29
29
  import { AGENTS, AGENTS_DIR, NHA_DIR, VERSION } from '../constants.mjs';
30
- import { getHTML } from '../services/web-ui.mjs';
30
+ import { getHTML, getJS } from '../services/web-ui.mjs';
31
31
  import { loadChatHistory, saveChatHistory, extractMemory, buildMemoryContext } from '../services/memory.mjs';
32
32
  import {
33
33
  createConversation,
@@ -244,6 +244,7 @@ export async function cmdUI(args) {
244
244
 
245
245
  const config = loadConfig();
246
246
  const htmlPage = getHTML(port);
247
+ const jsBundle = getJS();
247
248
 
248
249
  // Migrate old chat history to multi-conversation format
249
250
  migrateOldHistory();
@@ -285,6 +286,17 @@ export async function cmdUI(args) {
285
286
  return;
286
287
  }
287
288
 
289
+ // ── JS bundle ───────────────────────────────────────────────────
290
+ if (method === 'GET' && pathname.startsWith('/nha-ui.js')) {
291
+ res.writeHead(200, {
292
+ 'Content-Type': 'application/javascript; charset=utf-8',
293
+ 'Cache-Control': 'public, max-age=3600',
294
+ });
295
+ res.end(jsBundle);
296
+ logRequest(method, pathname, 200, Date.now() - start);
297
+ return;
298
+ }
299
+
288
300
  // ── PWA Manifest ────────────────────────────────────────────────
289
301
  if (pathname === '/manifest.json') {
290
302
  sendJSON(res, 200, {
@@ -335,40 +347,15 @@ export async function cmdUI(args) {
335
347
 
336
348
  // POST /api/google/auth — trigger Google OAuth flow from web UI
337
349
  if (method === 'POST' && pathname === '/api/google/auth') {
338
- // Check if Google credentials are configured first
339
- const clientId = config.google?.clientId || '';
340
- const clientSecret = config.google?.clientSecret || '';
341
- if (!clientId) {
342
- sendJSON(res, 200, {
343
- ok: false,
344
- needsSetup: true,
345
- message: 'Google OAuth not configured yet.\n\n' +
346
- 'To connect Google services, you need OAuth credentials:\n\n' +
347
- '1. Go to https://console.cloud.google.com/apis/credentials\n' +
348
- '2. Create an OAuth 2.0 Client ID (Desktop app type)\n' +
349
- '3. Enable Gmail API, Calendar API, Drive API, People API, Tasks API\n' +
350
- '4. Add authorized redirect URIs: http://127.0.0.1:19847/callback through http://127.0.0.1:19851/callback\n' +
351
- '5. In the NHA terminal, run:\n' +
352
- ' nha config set google-client-id YOUR_CLIENT_ID\n' +
353
- ' nha config set google-client-secret YOUR_CLIENT_SECRET\n' +
354
- '6. Then click "Connect Google" again.',
355
- });
356
- logRequest(method, pathname, 200, Date.now() - start);
357
- return;
358
- }
359
350
  try {
360
351
  const { runAuthFlow } = await import('../services/google-oauth.mjs');
361
- const success = await runAuthFlow(config);
362
- if (success) {
363
- config._googleConnected = true;
364
- const freshConfig = await loadConfig();
365
- Object.assign(config, freshConfig);
366
- sendJSON(res, 200, { ok: true, message: 'Google connected successfully! You can now use email, calendar, contacts, and Drive.' });
367
- } else {
368
- sendJSON(res, 200, { ok: false, message: 'Google OAuth failed. The browser window should have opened at accounts.google.com. If it didn\'t, try running "nha google" from the terminal.' });
369
- }
352
+ // Run auth flow in background — opens browser
353
+ runAuthFlow(config).then(success => {
354
+ if (success) config._googleConnected = true;
355
+ }).catch(() => {});
356
+ sendJSON(res, 200, { ok: true, message: 'OAuth flow started. Check the browser window that opened.' });
370
357
  } catch (e) {
371
- sendJSON(res, 500, { error: `Google OAuth error: ${e.message}. Try running "nha google" from the terminal.` });
358
+ sendJSON(res, 500, { error: e.message });
372
359
  }
373
360
  logRequest(method, pathname, 200, Date.now() - start);
374
361
  return;
@@ -631,14 +618,6 @@ export async function cmdUI(args) {
631
618
  // GET /api/config — read config values for settings UI
632
619
  if (method === 'GET' && pathname === '/api/config') {
633
620
  // Return non-sensitive config for the settings form
634
- // Sanitize email accounts — don't expose passwords to frontend
635
- const safeAccounts = (config.emailAccounts || []).map(a => ({
636
- label: a.label,
637
- address: a.address,
638
- isDefault: a.isDefault,
639
- hasImap: !!(a.imap?.host),
640
- hasSmtp: !!(a.smtp?.host),
641
- }));
642
621
  sendJSON(res, 200, {
643
622
  profile: config.profile || {},
644
623
  provider: config.llm?.provider || '',
@@ -649,8 +628,6 @@ export async function cmdUI(args) {
649
628
  meetingAlert: config.ops?.meetingAlertMinutes || 30,
650
629
  hasTelegram: !!config.responder?.telegram?.token,
651
630
  hasDiscord: !!config.responder?.discord?.token,
652
- hasGoogle: !!config._googleConnected,
653
- emailAccounts: safeAccounts,
654
631
  });
655
632
  logRequest(method, pathname, 200, Date.now() - start);
656
633
  return;
@@ -1270,7 +1247,7 @@ export async function cmdUI(args) {
1270
1247
  }
1271
1248
  }
1272
1249
 
1273
- if (!config.llm.apiKey && config.llm.provider !== 'nha') {
1250
+ if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
1274
1251
  config.llm.provider = 'nha'; // Auto-fallback to free tier
1275
1252
  }
1276
1253
 
@@ -1292,14 +1269,12 @@ export async function cmdUI(args) {
1292
1269
  if (sLines.length > 0) parts.push(`[CONTEXT — ${sLines.length} earlier exchanges]\n${sLines.join('\n')}\n[END CONTEXT]`);
1293
1270
  }
1294
1271
  for (const turn of requestHistory.slice(-RECENT)) {
1295
- // Use llmContent (file context) when available, otherwise display content
1296
- const turnContent = turn.llmContent || turn.content;
1297
- parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turnContent.slice(0, 4000)}`);
1272
+ parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turn.content.slice(0, 2000)}`);
1298
1273
  }
1299
1274
  parts.push(`[User] ${body.message}`);
1300
1275
  let userMessage = parts.join('\n\n');
1301
1276
 
1302
- // Inject episodic memory + cross-conversation memory into the system prompt
1277
+ // Inject episodic memory context into the system prompt
1303
1278
  const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
1304
1279
  let enrichedSystemPrompt = basePrompt;
1305
1280
  try {
@@ -1307,37 +1282,6 @@ export async function cmdUI(args) {
1307
1282
  if (memCtx) enrichedSystemPrompt = basePrompt + memCtx;
1308
1283
  } catch { /* memory unavailable */ }
1309
1284
 
1310
- // Cross-conversation memory — summaries of recent conversations
1311
- try {
1312
- const allConvs = listConversations();
1313
- if (allConvs.length > 1) {
1314
- const summaries = [];
1315
- let totalChars = 0;
1316
- for (const c of allConvs) {
1317
- if (c.id === (body.conversationId || activeConvId)) continue;
1318
- if (summaries.length >= 8 || totalChars > 2000) break;
1319
- if (!c.messages || c.messages.length === 0) continue;
1320
- const firstUser = c.messages.find(m => m.role === 'user');
1321
- const lastAssistant = [...c.messages].reverse().find(m => m.role === 'assistant');
1322
- if (!firstUser) continue;
1323
- const date = c.updatedAt?.split('T')[0] || '?';
1324
- const title = c.title !== 'New Chat' ? c.title : firstUser.content.slice(0, 60);
1325
- let s = `• [${date}] "${title}" (${c.messages.length} msgs)`;
1326
- s += `\n User: ${firstUser.content.replace(/\s+/g, ' ').slice(0, 120)}`;
1327
- if (lastAssistant) {
1328
- const preview = lastAssistant.content.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/\s+/g, ' ').slice(0, 150);
1329
- s += `\n Result: ${preview}`;
1330
- }
1331
- if (totalChars + s.length > 2000) break;
1332
- summaries.push(s);
1333
- totalChars += s.length;
1334
- }
1335
- if (summaries.length > 0) {
1336
- enrichedSystemPrompt += `\n\n--- CONVERSATION MEMORY ---\nYou remember these past conversations:\n\n${summaries.join('\n\n')}\n\nUse this to maintain continuity. Never say "I don't have access to previous conversations".\n--- END MEMORY ---`;
1337
- }
1338
- }
1339
- } catch { /* non-critical */ }
1340
-
1341
1285
  // Handle image attachment — vision API
1342
1286
  if (body.imageBase64 && body.imageMimeType) {
1343
1287
  try {
@@ -1430,33 +1374,13 @@ export async function cmdUI(args) {
1430
1374
  const pdfPrompt = body.message || `Read and analyze this PDF document "${body.pdfName}". Extract all text content, summarize key information.`;
1431
1375
  let pdfResponse = '';
1432
1376
 
1433
- // Step 1: Extract text — try server (pdftotext) first, then local fallback
1434
- let pdfText = '';
1435
- try {
1436
- const extractRes = await fetch('https://nothumanallowed.com/api/v1/tools/extract-pdf', {
1437
- method: 'POST',
1438
- headers: { 'Content-Type': 'application/json', 'X-NHA-Client': 'desktop' },
1439
- body: JSON.stringify({ base64: body.pdfBase64 }),
1440
- signal: AbortSignal.timeout(30000),
1441
- });
1442
- if (extractRes.ok) {
1443
- const d = await extractRes.json();
1444
- pdfText = d.text || '';
1445
- }
1446
- } catch { /* server unreachable */ }
1447
- // Local fallback if server extraction failed
1448
- if (pdfText.length < 20) {
1449
- const pdfBuffer = Buffer.from(body.pdfBase64, 'base64');
1450
- pdfText = extractTextFromPdf(pdfBuffer);
1451
- }
1452
-
1453
- // Save extracted text as llmContent so it persists across turns
1454
- const pdfLlmContent = pdfText.length > 20
1455
- ? `[PDF: ${body.pdfName}]\n\n${pdfText.slice(0, 12000)}\n\n---\n\nUser question: ${pdfPrompt}`
1456
- : '';
1457
-
1458
1377
  if (provider === 'nha') {
1378
+ // NHA Free tier: extract text from PDF, then send to Liara chat
1379
+ // Decode PDF base64 and extract text content
1380
+ const pdfBuffer = Buffer.from(body.pdfBase64, 'base64');
1381
+ const pdfText = extractTextFromPdf(pdfBuffer);
1459
1382
  if (!pdfText || pdfText.length < 10) {
1383
+ // Fallback: send first page as image to vision model
1460
1384
  const r = await fetch('https://nothumanallowed.com/api/v1/liara/vision', {
1461
1385
  method: 'POST',
1462
1386
  headers: { 'Content-Type': 'application/json' },
@@ -1469,6 +1393,7 @@ export async function cmdUI(args) {
1469
1393
  pdfResponse = 'Could not read this PDF. Try a text-based PDF or use Claude/Gemini for scanned documents.';
1470
1394
  }
1471
1395
  } else {
1396
+ // Send extracted text to Liara chat
1472
1397
  const truncatedText = pdfText.slice(0, 12000);
1473
1398
  const r = await fetch('https://nothumanallowed.com/api/v1/liara/chat', {
1474
1399
  method: 'POST',
@@ -1526,8 +1451,7 @@ export async function cmdUI(args) {
1526
1451
  pdfResponse = `PDF reading requires Anthropic (Claude) or Gemini. Your provider "${provider}" does not support native PDF documents.`;
1527
1452
  }
1528
1453
 
1529
- // Return llmContent so frontend can persist the PDF text across turns
1530
- sendJSON(res, 200, { response: pdfResponse, llmContent: pdfLlmContent || undefined });
1454
+ sendJSON(res, 200, { response: pdfResponse });
1531
1455
  logRequest(method, pathname, 200, Date.now() - start);
1532
1456
  return;
1533
1457
  } catch (e) {
@@ -1537,14 +1461,12 @@ export async function cmdUI(args) {
1537
1461
  }
1538
1462
  }
1539
1463
 
1540
- // Handle text file attachment — include file content as llmContent for persistence
1541
- let fileLlmContent = '';
1464
+ // Handle text file attachment
1542
1465
  if (body.fileContent && body.fileName) {
1543
1466
  const filePrompt = body.message
1544
1467
  ? `User asks about file "${body.fileName}": ${body.message}\n\nFile content:\n${body.fileContent.slice(0, 8000)}`
1545
1468
  : `Analyze this file "${body.fileName}":\n\n${body.fileContent.slice(0, 8000)}`;
1546
1469
  userMessage = filePrompt;
1547
- fileLlmContent = filePrompt;
1548
1470
  }
1549
1471
 
1550
1472
  try {
@@ -1563,7 +1485,11 @@ export async function cmdUI(args) {
1563
1485
  screenshotData = result;
1564
1486
  toolResults.push({ action, result: 'Screenshot captured. Analyzing with vision...' });
1565
1487
  } else {
1566
- toolResults.push({ action, result: typeof result === 'object' ? JSON.stringify(result) : String(result) });
1488
+ let rStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
1489
+ if ((action === 'web_search' || action === 'fetch_url') && rStr.includes('<')) {
1490
+ rStr = rStr.replace(/<style[\s\S]*?<\/style>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'').replace(/<[^>]+>/g,' ').replace(/\s{3,}/g,'\n').replace(/[^\x00-\x7F]/g,'').trim().slice(0,6000);
1491
+ }
1492
+ toolResults.push({ action, result: rStr });
1567
1493
  }
1568
1494
  } catch (e) {
1569
1495
  toolResults.push({ action, result: `Error: ${e.message}` });
@@ -1641,7 +1567,7 @@ export async function cmdUI(args) {
1641
1567
  } catch { /* non-critical */ }
1642
1568
  try { extractMemory('chat', body.message, fullResponse); } catch { /* non-critical */ }
1643
1569
 
1644
- sendJSON(res, 200, { response: fullResponse, toolResults, actions, ...(fileLlmContent ? { llmContent: fileLlmContent } : {}) });
1570
+ sendJSON(res, 200, { response: fullResponse, toolResults, actions });
1645
1571
  } catch (e) {
1646
1572
  sendJSON(res, 200, { response: null, error: e.message });
1647
1573
  }
@@ -1783,7 +1709,7 @@ export async function cmdUI(args) {
1783
1709
  if (method === 'POST' && pathname === '/api/chat/stream') {
1784
1710
  const body = await parseBody(req);
1785
1711
  if (!body.message) { sendJSON(res, 400, { error: 'message required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1786
- if (!config.llm.apiKey && config.llm.provider !== 'nha') { config.llm.provider = 'nha'; }
1712
+ if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) { config.llm.provider = 'nha'; }
1787
1713
 
1788
1714
  const msg = body.message.trim();
1789
1715
  const convId = body.conversationId;
@@ -1989,7 +1915,18 @@ export async function cmdUI(args) {
1989
1915
  continue;
1990
1916
  }
1991
1917
 
1992
- const resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
1918
+ let resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
1919
+ // For web_search/fetch_url: strip raw HTML/CSS so the LLM gets clean text
1920
+ if ((action === 'web_search' || action === 'fetch_url') && resultStr.includes('<')) {
1921
+ resultStr = resultStr
1922
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
1923
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
1924
+ .replace(/<[^>]+>/g, ' ')
1925
+ .replace(/\s{3,}/g, '\n')
1926
+ .replace(/[^\x00-\x7F]/g, '') // strip non-ASCII that causes encoding issues
1927
+ .trim()
1928
+ .slice(0, 6000);
1929
+ }
1993
1930
  toolResults.push({ action, result: resultStr });
1994
1931
  sendSSE('tool', { action, status: 'done', result: typeof resultStr === 'string' ? resultStr.slice(0, 500) : '' });
1995
1932
 
@@ -2280,7 +2217,7 @@ export async function cmdUI(args) {
2280
2217
  return;
2281
2218
  }
2282
2219
 
2283
- if (!config.llm.apiKey && config.llm.provider !== 'nha') {
2220
+ if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
2284
2221
  // Auto-fallback to NHA free tier if no API key is configured
2285
2222
  config.llm.provider = 'nha';
2286
2223
  }
@@ -2351,6 +2288,107 @@ export async function cmdUI(args) {
2351
2288
  return;
2352
2289
  }
2353
2290
 
2291
+ // POST /api/ask/stream — streaming SSE agent call
2292
+ if (method === 'POST' && pathname === '/api/ask/stream') {
2293
+ const body = await parseBody(req);
2294
+ if (!body.agent || !body.prompt) {
2295
+ sendJSON(res, 400, { error: 'agent and prompt required' });
2296
+ logRequest(method, pathname, 400, Date.now() - start);
2297
+ return;
2298
+ }
2299
+
2300
+ // Ensure provider is set — default to 'nha' free tier if no apiKey
2301
+ if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
2302
+ config.llm.provider = 'nha';
2303
+ }
2304
+
2305
+ const agentFileStream = path.join(AGENTS_DIR, `${body.agent}.mjs`);
2306
+ if (!AGENTS.includes(body.agent) && !fs.existsSync(agentFileStream)) {
2307
+ sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
2308
+ logRequest(method, pathname, 400, Date.now() - start);
2309
+ return;
2310
+ }
2311
+
2312
+ res.writeHead(200, {
2313
+ 'Content-Type': 'text/event-stream',
2314
+ 'Cache-Control': 'no-cache',
2315
+ 'Connection': 'keep-alive',
2316
+ 'Access-Control-Allow-Origin': '*',
2317
+ });
2318
+
2319
+ const sendEv = (obj) => { try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {} };
2320
+
2321
+ try {
2322
+ let context = '';
2323
+ try {
2324
+ const [emails, events] = await Promise.all([
2325
+ getUnreadImportant(config, 15).catch(() => []),
2326
+ getTodayEvents(config).catch(() => []),
2327
+ ]);
2328
+ const tasks = getTasks();
2329
+ if (emails.length > 0) {
2330
+ context += '\n\n[USER EMAIL CONTEXT]\n';
2331
+ emails.slice(0, 10).forEach((e, i) => {
2332
+ context += `${i + 1}. From: ${e.from} | Subject: ${e.subject}\n ${e.snippet.slice(0, 150)}\n`;
2333
+ });
2334
+ }
2335
+ if (events.length > 0) {
2336
+ context += '\n\n[USER CALENDAR — today]\n';
2337
+ events.forEach(e => {
2338
+ context += `${e.isAllDay ? 'All day' : e.start + ' - ' + e.end}: ${e.summary}\n`;
2339
+ });
2340
+ }
2341
+ if (tasks.length > 0) {
2342
+ context += '\n\n[USER TASKS]\n';
2343
+ tasks.forEach(t => { context += `#${t.id} [${t.priority}] ${t.description}\n`; });
2344
+ }
2345
+ } catch { /* proceed without context */ }
2346
+
2347
+ let fileContext = '';
2348
+ if (body.fileContent && body.fileName) {
2349
+ fileContext = '\n\n--- Attached: ' + body.fileName + ' ---\n' + String(body.fileContent).slice(0, 100000);
2350
+ }
2351
+
2352
+ const enrichedPrompt = body.prompt + fileContext + (context
2353
+ ? '\n\nIMPORTANT CONTEXT: The data below is from the user\'s OWN accounts.\n' + context : '');
2354
+
2355
+ if (!fs.existsSync(agentFileStream)) {
2356
+ sendEv({ error: `Agent "${body.agent}" not downloaded. Run: nha update` });
2357
+ sendEv({ done: true });
2358
+ res.end();
2359
+ logRequest(method, pathname, 200, Date.now() - start);
2360
+ return;
2361
+ }
2362
+ const src = fs.readFileSync(agentFileStream, 'utf-8');
2363
+ const { systemPrompt } = parseAgentFile(src, body.agent);
2364
+ let enrichedSystem = systemPrompt;
2365
+ try {
2366
+ const { buildMemoryContext } = await import('../services/memory.mjs');
2367
+ const mc = buildMemoryContext(body.agent, enrichedPrompt);
2368
+ if (mc) enrichedSystem = systemPrompt + mc;
2369
+ } catch { /* proceed */ }
2370
+
2371
+ let fullResponse = '';
2372
+ await callLLMStream(config, enrichedSystem, enrichedPrompt, (token) => {
2373
+ fullResponse += token;
2374
+ sendEv({ token });
2375
+ });
2376
+ sendEv({ done: true });
2377
+
2378
+ try {
2379
+ const { extractMemory } = await import('../services/memory.mjs');
2380
+ extractMemory(body.agent, enrichedPrompt, fullResponse);
2381
+ } catch { /* non-critical */ }
2382
+
2383
+ } catch (e) {
2384
+ sendEv({ error: e.message });
2385
+ }
2386
+
2387
+ res.end();
2388
+ logRequest(method, pathname, 200, Date.now() - start);
2389
+ return;
2390
+ }
2391
+
2354
2392
  // ── GitHub ───────────────────────────────────────────────────────
2355
2393
  if (method === 'GET' && pathname === '/api/github') {
2356
2394
  try {
@@ -2499,6 +2537,339 @@ export async function cmdUI(args) {
2499
2537
  return;
2500
2538
  }
2501
2539
 
2540
+ // ── Studio: plan workflow ────────────────────────────────────────
2541
+ if (pathname === '/api/studio/plan' && method === 'POST') {
2542
+ const body = await parseBody(req);
2543
+ const task = (body.task || '').trim();
2544
+ if (!task) { sendJSON(res, 400, { error: 'task required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
2545
+
2546
+ const planPrompt = `You are a workflow planner for NHA Studio. The user wants to accomplish this task: "${task}"
2547
+
2548
+ Design a sequential workflow of 2-5 steps. RULES:
2549
+ - Use tool-agents (WebSearchAgent, EmailAgent, CalendarAgent, GitHubAgent, NotionAgent, SlackAgent) FIRST when real live data is needed
2550
+ - Use specialist agents (SABER, ATLAS, JARVIS, etc.) for deep domain analysis — they have rich expert system prompts
2551
+ - Use CanvasAgent as the LAST step ONLY when a visual HTML report is requested
2552
+ - The "prompt" field must be a plain language instruction — never JSON or code
2553
+ - Each agent receives the previous step's output as context automatically
2554
+ - Pick the most relevant agents for the task — don't use all of them
2555
+
2556
+ TOOL AGENTS (fetch real live data):
2557
+ - WebSearchAgent: search the web for current information
2558
+ - EmailAgent: read user's real unread emails
2559
+ - CalendarAgent: read user's real calendar events for today
2560
+ - GitHubAgent: read user's GitHub notifications and issues
2561
+ - NotionAgent: search user's Notion workspace
2562
+ - SlackAgent: read user's Slack messages
2563
+ - WriterAgent: write, summarize, synthesize text (no live data)
2564
+ - SummaryAgent: condense and summarize content
2565
+ - DataAnalystAgent: analyze data, find patterns, generate insights
2566
+ - SecurityAgent: security audit, threat analysis
2567
+ - DevOpsAgent: infrastructure, deployment, CI/CD analysis
2568
+ - CanvasAgent: generate a beautiful HTML visual dashboard (LAST step only)
2569
+
2570
+ SPECIALIST AGENTS (deep domain experts with rich system prompts):
2571
+ - SABER: security audits, OWASP, penetration testing, vulnerability analysis
2572
+ - ATLAS: infrastructure-as-code, Terraform, Kubernetes, cloud architecture
2573
+ - JARVIS: full-stack architecture, API design, system design, ADRs
2574
+ - VERITAS: fact-checking, evidence verification, claim validation
2575
+ - CASSANDRA: risk analysis, failure modes, worst-case scenarios
2576
+ - MERCURY: financial analysis, ROI, unit economics, market modeling
2577
+ - HERALD: news analysis, trend detection, executive briefings
2578
+ - ATHENA: tech evaluation, framework comparison, benchmarks
2579
+ - ORACLE: business intelligence, KPIs, OKRs, dashboards
2580
+ - NAVI: data exploration, statistical analysis, pattern detection
2581
+ - MUSE: creative brainstorming, ideation, naming, taglines
2582
+ - QUILL: short-form content, summaries, press releases
2583
+ - SCHEHERAZADE: long-form technical writing, documentation, tutorials
2584
+ - ECHO: content adaptation, cross-platform distribution
2585
+ - POLYGLOT: translation, localization, multilingual content
2586
+ - FORGE: CI/CD pipelines, GitHub Actions, Docker builds
2587
+ - FLUX: deployment strategies, blue/green, canary releases
2588
+ - SHOGUN: Kubernetes, Helm, container orchestration
2589
+ - PIPE: data pipelines, Airflow, dbt, ETL
2590
+ - MACRO: bulk operations, data migration, batch processing
2591
+ - SHELL: shell scripting, CLI tools, automation scripts
2592
+ - CONDUCTOR: workflow orchestration, task decomposition
2593
+ - CRON: scheduling, cron jobs, time-based automation
2594
+ - HERMES: webhooks, event-driven architecture, integrations
2595
+ - BABEL: API design, microservices, OpenAPI specs
2596
+ - CARTOGRAPHER: data mapping, schema inference, knowledge graphs
2597
+ - LOGOS: logical analysis, argument mapping, decision theory
2598
+ - EDI: A/B testing, statistical modeling, hypothesis testing
2599
+ - EPICURE: nutrition, recipes, meal planning
2600
+ - MURASAKI: creative writing, storytelling, narrative craft
2601
+ - LINK: community management, reputation systems
2602
+ - GLITCH: chaos engineering, resilience testing
2603
+ - TEMPEST: performance engineering, load testing
2604
+ - SAURON: observability, monitoring, alerting
2605
+ - PROMETHEUS: capability routing, task decomposition
2606
+ - ADE: agent design, system prompt engineering
2607
+ - ZERO: vulnerability scanning, dependency audit, secret detection
2608
+
2609
+ Icon values must be actual emoji characters — never HTML entities.
2610
+
2611
+ Respond with ONLY valid JSON, no markdown:
2612
+ {"steps":[{"icon":"🔍","agent":"WebSearchAgent","label":"Search AI news","prompt":"Search for the latest AI agent news"},{"icon":"🧠","agent":"ATHENA","label":"Tech analysis","prompt":"Analyze the search results and compare the key technologies mentioned"}]}`;
2613
+
2614
+ try {
2615
+ const planRaw = await callLLM(config, 'You are a JSON workflow planner. Respond only with valid JSON.', planPrompt, { max_tokens: 800 });
2616
+ let steps;
2617
+ try {
2618
+ const jsonMatch = planRaw.match(/\{[\s\S]*\}/);
2619
+ const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : planRaw);
2620
+ steps = parsed.steps;
2621
+ } catch {
2622
+ sendJSON(res, 500, { error: 'Failed to parse workflow plan' });
2623
+ logRequest(method, pathname, 500, Date.now() - start);
2624
+ return;
2625
+ }
2626
+ if (!Array.isArray(steps) || !steps.length) {
2627
+ sendJSON(res, 500, { error: 'Empty workflow plan' });
2628
+ logRequest(method, pathname, 500, Date.now() - start);
2629
+ return;
2630
+ }
2631
+ sendJSON(res, 200, { steps });
2632
+ logRequest(method, pathname, 200, Date.now() - start);
2633
+ } catch (e) {
2634
+ sendJSON(res, 500, { error: e.message });
2635
+ logRequest(method, pathname, 500, Date.now() - start);
2636
+ }
2637
+ return;
2638
+ }
2639
+
2640
+ // ── Studio: run single step (SSE streaming) ──────────────────────
2641
+ if (pathname === '/api/studio/run' && method === 'POST') {
2642
+ const body = await parseBody(req);
2643
+ const { agent, task, context, stepDef } = body;
2644
+ if (!agent || !task) { sendJSON(res, 400, { error: 'agent and task required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
2645
+
2646
+ res.writeHead(200, {
2647
+ 'Content-Type': 'text/event-stream',
2648
+ 'Cache-Control': 'no-cache',
2649
+ 'Connection': 'keep-alive',
2650
+ 'Access-Control-Allow-Origin': '*',
2651
+ });
2652
+
2653
+ const sendEvent = (data) => {
2654
+ try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}
2655
+ };
2656
+ const sendToken = (t) => sendEvent({ token: t });
2657
+
2658
+ // Keepalive: send a comment every 5s so the connection doesn't time out during slow tool calls
2659
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 5000);
2660
+
2661
+ // Timeout wrapper for tool calls — 25s max
2662
+ const withTimeout = (promise, label) => Promise.race([
2663
+ promise,
2664
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timed out after 25s`)), 25000)),
2665
+ ]);
2666
+
2667
+ try {
2668
+ const stepPrompt = stepDef?.prompt || task;
2669
+ let toolData = '';
2670
+
2671
+ // ── Fetch REAL data for each agent type ──────────────────────
2672
+ if (agent === 'EmailAgent') {
2673
+ sendToken('[Reading emails...] ');
2674
+ try {
2675
+ const emails = await withTimeout(getUnreadImportant(config, 10), 'EmailAgent');
2676
+ toolData = emails && emails.length
2677
+ ? emails.map(e => `From: ${e.from}\nSubject: ${e.subject}\nDate: ${e.date}\nSnippet: ${e.snippet}`).join('\n\n---\n\n')
2678
+ : 'No unread emails found.';
2679
+ } catch (e) { toolData = `Email read failed: ${e.message}`; }
2680
+
2681
+ } else if (agent === 'CalendarAgent') {
2682
+ sendToken('[Reading calendar...] ');
2683
+ try {
2684
+ const events = await withTimeout(getTodayEvents(config), 'CalendarAgent');
2685
+ toolData = events && events.length
2686
+ ? events.map(e => `${e.summary || e.title} - ${e.start || ''} to ${e.end || ''}`).join('\n')
2687
+ : 'No events found for today.';
2688
+ } catch (e) { toolData = `Calendar read failed: ${e.message}`; }
2689
+
2690
+ } else if (agent === 'WebSearchAgent' || agent === 'ResearchAgent') {
2691
+ sendToken('[Searching the web...] ');
2692
+ try {
2693
+ const searchResult = await withTimeout(executeTool('web_search', { query: stepPrompt }, config), 'WebSearch');
2694
+ toolData = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult);
2695
+ } catch (e) { toolData = `Web search failed: ${e.message}`; }
2696
+
2697
+ } else if (agent === 'BrowserAgent') {
2698
+ const urlMatch = stepPrompt.match(/https?:\/\/[^\s"']+/);
2699
+ if (urlMatch) {
2700
+ sendToken(`[Fetching ${urlMatch[0]}...] `);
2701
+ try {
2702
+ const fetchResult = await withTimeout(executeTool('fetch_url', { url: urlMatch[0] }, config), 'BrowserAgent');
2703
+ toolData = typeof fetchResult === 'string' ? fetchResult : JSON.stringify(fetchResult);
2704
+ } catch (e) { toolData = `Fetch failed: ${e.message}`; }
2705
+ } else {
2706
+ sendToken('[Searching web...] ');
2707
+ try {
2708
+ const searchResult = await withTimeout(executeTool('web_search', { query: stepPrompt }, config), 'BrowserSearch');
2709
+ toolData = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult);
2710
+ } catch (e) { toolData = `Browser search failed: ${e.message}`; }
2711
+ }
2712
+
2713
+ } else if (agent === 'GitHubAgent') {
2714
+ sendToken('[Reading GitHub...] ');
2715
+ try {
2716
+ const gh = await import('../services/github.mjs');
2717
+ const issues = await withTimeout(gh.listIssues(config, config.githubRepo || '', 10), 'GitHubAgent');
2718
+ toolData = typeof issues === 'string' ? issues : JSON.stringify(issues);
2719
+ } catch (e) { toolData = `GitHub read failed: ${e.message}`; }
2720
+
2721
+ } else if (agent === 'NotionAgent') {
2722
+ sendToken('[Searching Notion...] ');
2723
+ try {
2724
+ const nt = await import('../services/notion.mjs');
2725
+ const results = await withTimeout(nt.search(config, stepPrompt, 10), 'NotionAgent');
2726
+ toolData = typeof results === 'string' ? results : JSON.stringify(results);
2727
+ } catch (e) { toolData = `Notion search failed: ${e.message}`; }
2728
+
2729
+ } else if (agent === 'SlackAgent') {
2730
+ sendToken('[Reading Slack...] ');
2731
+ try {
2732
+ const sl = await import('../services/slack.mjs');
2733
+ const channels = await withTimeout(sl.listChannels(config, 10), 'SlackAgent');
2734
+ toolData = typeof channels === 'string' ? channels : JSON.stringify(channels);
2735
+ } catch (e) { toolData = `Slack read failed: ${e.message}`; }
2736
+
2737
+ } else if (agent === 'DriveAgent') {
2738
+ sendToken('[Reading Drive...] ');
2739
+ try {
2740
+ const gd = await import('../services/google-drive.mjs');
2741
+ const files = await withTimeout(gd.listFiles(config, '', 10), 'DriveAgent');
2742
+ toolData = typeof files === 'string' ? files : JSON.stringify(files);
2743
+ } catch (e) { toolData = `Drive read failed: ${e.message}`; }
2744
+ }
2745
+
2746
+ // ── Build system prompt with real tool data ───────────────────
2747
+ const isCanvasAgent = agent === 'CanvasAgent';
2748
+ // Synthesis agents: do NOT wrap with TOOL_DEFINITIONS — use focused prompts only
2749
+ const isSynthesisAgent = ['WriterAgent','SummaryAgent','DataAnalystAgent','SecurityAgent','DevOpsAgent'].includes(agent);
2750
+ const isToolOutputAgent = ['CalendarAgent','EmailAgent','GitHubAgent','NotionAgent','SlackAgent'].includes(agent);
2751
+ const isPureAnalysis = isSynthesisAgent || isToolOutputAgent;
2752
+
2753
+ const canvasSystemPrompt = `You are an HTML report generator. Output a single complete HTML document. No preamble, no explanation.
2754
+ RULES:
2755
+ - First character of your response must be < (start of <!DOCTYPE html>)
2756
+ - Do NOT use markdown code blocks, JSON, or any wrapper
2757
+ - Use clean design: white background, Inter/system-ui font, #6366f1 accent color
2758
+ - Structure: gradient header, then card sections with the content
2759
+ - Make it complete and self-contained`;
2760
+
2761
+ let sysPrompt, userMsg;
2762
+
2763
+ if (isCanvasAgent) {
2764
+ sysPrompt = canvasSystemPrompt;
2765
+ userMsg = `Generate a beautiful HTML dashboard report for this content. Start immediately with <!DOCTYPE html>:\n\n${context.slice(0, 8000)}`;
2766
+ } else if (isSynthesisAgent) {
2767
+ // Focused system prompt — no TOOL_DEFINITIONS bloat
2768
+ const today = new Date().toISOString().split('T')[0];
2769
+ const language = config?.language || 'Italian';
2770
+ sysPrompt = `You are ${agent}, a specialist AI agent inside NHA Studio. Today is ${today}. Respond in ${language}.
2771
+ Task: ${stepPrompt}
2772
+ ${toolData ? `\n## LIVE DATA:\n${toolData.slice(0, 4000)}\n` : ''}
2773
+ ${context ? `\n## CONTEXT FROM PREVIOUS STEPS:\n${context.slice(0, 5000)}\n` : ''}
2774
+ Write your full response in plain prose. Do NOT output JSON, tool calls, or code blocks unless explicitly asked. Use the context and data above — do not ask for more information.`;
2775
+ userMsg = toolData
2776
+ ? `Use the live data and context above to complete: ${stepPrompt}`
2777
+ : context
2778
+ ? `Based on the context above, complete: ${stepPrompt}`
2779
+ : stepPrompt;
2780
+ } else {
2781
+ const agentInstruction = `You are ${agent}, a specialist AI agent inside NHA Studio.\nYour task: ${stepPrompt}\n` +
2782
+ (toolData ? `\n## DATA FROM TOOLS:\n${toolData.slice(0, 4000)}\n` : '') +
2783
+ (context ? `\n## OUTPUT FROM PREVIOUS AGENTS:\n${context.slice(0, 3000)}\n` : '') +
2784
+ (isToolOutputAgent
2785
+ ? '\nWrite your analysis in plain text. Do NOT output JSON, tool calls, or code blocks. Use the context above.'
2786
+ : '\nOutput your result directly. No preamble.');
2787
+ sysPrompt = buildSystemPrompt(agent, agentInstruction, config);
2788
+ userMsg = toolData
2789
+ ? `Use the data above to complete: ${stepPrompt}`
2790
+ : context
2791
+ ? `Based on the previous output, complete: ${stepPrompt}`
2792
+ : stepPrompt;
2793
+ }
2794
+
2795
+ // ── Stream LLM response ───────────────────────────────────────
2796
+ let fullOutput = '';
2797
+ sendToken(isCanvasAgent ? 'Generating visual report...' : '');
2798
+ try {
2799
+ await withTimeout(
2800
+ callLLMStream(config, sysPrompt, userMsg,
2801
+ (token) => { fullOutput += token; if (!isCanvasAgent) sendToken(token); },
2802
+ ),
2803
+ isCanvasAgent ? 60000 : 35000
2804
+ );
2805
+ } catch (e) {
2806
+ if (!isCanvasAgent) sendToken(`[Error: ${e.message}]`);
2807
+ }
2808
+
2809
+ if (isCanvasAgent) {
2810
+ let html = fullOutput.trim();
2811
+ // Strip thinking tags if not already filtered
2812
+ html = html.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
2813
+ // Extract from markdown code block
2814
+ const mdMatch = html.match(/```html?\s*([\s\S]*?)```/i);
2815
+ if (mdMatch) html = mdMatch[1].trim();
2816
+ // Find <!DOCTYPE or <html start if there's preamble text
2817
+ const doctypeIdx = html.indexOf('<!DOCTYPE');
2818
+ const htmlTagIdx = html.indexOf('<html');
2819
+ const startIdx = doctypeIdx >= 0 ? doctypeIdx : (htmlTagIdx >= 0 ? htmlTagIdx : -1);
2820
+ if (startIdx > 0) html = html.slice(startIdx);
2821
+ // Fallback: build clean HTML from the context directly (no LLM needed)
2822
+ if (!html.trim() || !html.includes('<')) {
2823
+ // Try splitting on markdown headings first, then numbered items, then double newlines
2824
+ let sections = context.split(/\n#{1,3} /).filter(s => s.trim());
2825
+ if (sections.length <= 1) sections = context.split(/\n(?=\*\*\d+[\.\)])|(?=^\d+[\.\)])/).filter(s => s.trim());
2826
+ if (sections.length <= 1) sections = context.split(/\n{2,}/).filter(s => s.trim());
2827
+ const reportTitle = (task.slice(0, 80) || 'NHA Studio Report').replace(/</g,'&lt;').replace(/>/g,'&gt;');
2828
+ const cardsHtml = sections.map(s => {
2829
+ const clean = s.replace(/\*\*/g, '').replace(/\*/g, '').trim();
2830
+ const lines = clean.split('\n').filter(Boolean);
2831
+ const titleLine = lines[0] || '';
2832
+ const bodyLines = lines.slice(1).join('\n').trim();
2833
+ return `<div class="card">${titleLine ? `<h2>${titleLine.replace(/</g,'&lt;')}</h2>` : ''}<p>${(bodyLines || titleLine).replace(/\n/g, '</p><p>').replace(/</g,'&lt;')}</p></div>`;
2834
+ }).join('');
2835
+ const safeContext = context.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'</p><p>');
2836
+ html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Report</title><style>
2837
+ *{box-sizing:border-box;margin:0;padding:0}
2838
+ body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;color:#1e293b;padding:0}
2839
+ .header{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:48px 40px;margin-bottom:32px}
2840
+ .header h1{font-size:1.8em;font-weight:700;margin-bottom:8px}
2841
+ .header p{opacity:.85;font-size:1em}
2842
+ .content{max-width:900px;margin:0 auto;padding:0 32px 48px}
2843
+ .card{background:#fff;border-radius:12px;padding:28px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);border:1px solid #e2e8f0}
2844
+ .card h2{color:#6366f1;font-size:1.05em;font-weight:700;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid #f1f5f9}
2845
+ .card p{color:#475569;line-height:1.75;margin-bottom:8px}
2846
+ .card p:last-child{margin-bottom:0}
2847
+ </style></head><body>
2848
+ <div class="header"><h1>${reportTitle}</h1><p>Report generated by NHA Studio</p></div>
2849
+ <div class="content">${cardsHtml || '<div class="card"><p>' + safeContext + '</p></div>'}</div>
2850
+ </body></html>`;
2851
+ }
2852
+ sendToken('\n\n[Report generato]');
2853
+ sendEvent({ canvas: html });
2854
+ }
2855
+
2856
+ // Estimate token usage (aprox: 1 token ≈ 4 chars)
2857
+ const inTokens = Math.ceil((sysPrompt.length + userMsg.length) / 4);
2858
+ const outTokens = Math.ceil(fullOutput.length / 4);
2859
+ clearInterval(keepalive);
2860
+ sendEvent({ usage: { input: inTokens, output: outTokens } });
2861
+ sendEvent({ done: true });
2862
+ res.write('data: [DONE]\n\n');
2863
+ res.end();
2864
+ } catch (e) {
2865
+ clearInterval(keepalive);
2866
+ sendEvent({ error: e.message });
2867
+ res.end();
2868
+ }
2869
+ logRequest(method, pathname, 200, Date.now() - start);
2870
+ return;
2871
+ }
2872
+
2502
2873
  // ── 404 ──────────────────────────────────────────────────────────
2503
2874
  sendJSON(res, 404, { error: 'Not found' });
2504
2875
  logRequest(method, pathname, 404, Date.now() - start);