nothumanallowed 9.5.2 → 9.7.0

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.
@@ -15,7 +15,7 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { loadConfig } from '../config.mjs';
17
17
  import { detectMailProvider, hasMailProvider, getProviderStatus } from '../services/mail-router.mjs';
18
- import { callLLM, callLLMStream, callAgent, parseAgentFile } from '../services/llm.mjs';
18
+ import { callLLM, callLLMVision, callAgent, parseAgentFile } from '../services/llm.mjs';
19
19
  import { getUnreadImportant, getMessage, listMessages, sendEmail, createDraft } from '../services/mail-router.mjs';
20
20
  import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, getEventsForDate } from '../services/mail-router.mjs';
21
21
  import {
@@ -28,20 +28,6 @@ import { runPlanningPipeline } from '../services/ops-pipeline.mjs';
28
28
  import { AGENTS, AGENTS_DIR, NHA_DIR, VERSION } from '../constants.mjs';
29
29
  import { getHTML } from '../services/web-ui.mjs';
30
30
  import { loadChatHistory, saveChatHistory, extractMemory, buildMemoryContext } from '../services/memory.mjs';
31
- import {
32
- createConversation,
33
- loadConversation,
34
- saveConversation,
35
- deleteConversation,
36
- listConversations,
37
- getOrCreateActive,
38
- setActiveId,
39
- getHistory,
40
- addMessages,
41
- exportAsMarkdown,
42
- exportAsJson,
43
- migrateOldHistory,
44
- } from '../services/conversations.mjs';
45
31
  import { info, ok, fail, warn, C, G, D, NC, BOLD } from '../ui.mjs';
46
32
  import {
47
33
  parseActions,
@@ -92,7 +78,7 @@ function sendJSON(res, statusCode, data) {
92
78
  res.writeHead(statusCode, {
93
79
  'Content-Type': 'application/json',
94
80
  'Access-Control-Allow-Origin': '*',
95
- 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
81
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
96
82
  'Access-Control-Allow-Headers': 'Content-Type',
97
83
  'Cache-Control': 'no-cache',
98
84
  });
@@ -165,9 +151,6 @@ export async function cmdUI(args) {
165
151
  const config = loadConfig();
166
152
  const htmlPage = getHTML(port);
167
153
 
168
- // Migrate old chat history to multi-conversation format
169
- migrateOldHistory();
170
-
171
154
  // Pre-load agent cards once at startup
172
155
  const agentCards = loadAgentCards();
173
156
 
@@ -175,7 +158,12 @@ export async function cmdUI(args) {
175
158
  const UI_PERSONA = `You are NHA Chat, a personal operations assistant inside the NotHumanAllowed web UI. ` +
176
159
  `You help the user manage their emails, calendar, tasks, GitHub issues, Notion pages, and Slack channels through natural conversation. ` +
177
160
  `Be concise, helpful, and proactive. When presenting data, format it clearly. ` +
178
- `Never output raw JSON to the user.`;
161
+ `Never output raw JSON to the user.\n\n` +
162
+ `ABSOLUTE RULE — NEVER LIE: You MUST ALWAYS tell the truth. NEVER fabricate, invent, or guess information. ` +
163
+ `If you don't know something, say "I don't know." If a tool fails, say it failed. If you cannot see something, say you cannot see it. ` +
164
+ `If you receive a screenshot but cannot analyze it (no vision support), say so honestly. ` +
165
+ `NEVER describe things you haven't actually seen or data you haven't actually received. ` +
166
+ `Honesty is MORE important than being helpful. A truthful "I don't know" is ALWAYS better than a fabricated answer.`;
179
167
  const chatSystemPrompt = buildSystemPrompt('NHA UI', UI_PERSONA, config);
180
168
 
181
169
  // ── Route Handlers ──────────────────────────────────────────────────────
@@ -190,7 +178,7 @@ export async function cmdUI(args) {
190
178
  if (method === 'OPTIONS') {
191
179
  res.writeHead(204, {
192
180
  'Access-Control-Allow-Origin': '*',
193
- 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
181
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
194
182
  'Access-Control-Allow-Headers': 'Content-Type',
195
183
  });
196
184
  res.end();
@@ -233,46 +221,22 @@ export async function cmdUI(args) {
233
221
 
234
222
  // ── API Routes ────────────────────────────────────────────────────
235
223
 
236
- // GET /api/screenshots/:filename — serve saved screenshots from disk
224
+ // GET /api/screenshots/:filename — serve screenshot files
237
225
  if (method === 'GET' && pathname.startsWith('/api/screenshots/')) {
238
- const ssName = pathname.split('/').pop();
239
- if (!ssName || ssName.includes('..') || !ssName.endsWith('.jpg')) {
240
- sendJSON(res, 404, { error: 'not found' });
241
- logRequest(method, pathname, 404, Date.now() - start);
242
- return;
243
- }
244
- const ssPath = path.join(NHA_DIR, 'screenshots', ssName);
245
- if (!fs.existsSync(ssPath)) {
246
- sendJSON(res, 404, { error: 'screenshot not found' });
247
- logRequest(method, pathname, 404, Date.now() - start);
248
- return;
249
- }
250
- res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=86400' });
251
- res.end(fs.readFileSync(ssPath));
252
- logRequest(method, pathname, 200, Date.now() - start);
253
- return;
254
- }
255
-
256
- // POST /api/google/auth — trigger Google OAuth flow from web UI
257
- if (method === 'POST' && pathname === '/api/google/auth') {
258
- try {
259
- const { runAuthFlow } = await import('../services/google-oauth.mjs');
260
- // Run auth flow in background — opens browser
261
- runAuthFlow(config).then(success => {
262
- if (success) config._googleConnected = true;
263
- }).catch(() => {});
264
- sendJSON(res, 200, { ok: true, message: 'OAuth flow started. Check the browser window that opened.' });
265
- } catch (e) {
266
- sendJSON(res, 500, { error: e.message });
226
+ const filename = decodeURIComponent(pathname.split('/').pop());
227
+ // Prevent path traversal
228
+ if (filename.includes('..') || filename.includes('/')) {
229
+ res.writeHead(400); res.end('Bad request'); return;
230
+ }
231
+ const os = await import('os');
232
+ const screenshotPath = path.join(os.default.tmpdir(), 'nha-screenshots', filename);
233
+ if (fs.existsSync(screenshotPath)) {
234
+ const data = fs.readFileSync(screenshotPath);
235
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });
236
+ res.end(data);
237
+ } else {
238
+ res.writeHead(404); res.end('Not found');
267
239
  }
268
- logRequest(method, pathname, 200, Date.now() - start);
269
- return;
270
- }
271
-
272
- // GET /api/health — simple health check
273
- if (method === 'GET' && pathname === '/api/health') {
274
- sendJSON(res, 200, { ok: true, version: VERSION });
275
- logRequest(method, pathname, 200, Date.now() - start);
276
240
  return;
277
241
  }
278
242
 
@@ -350,40 +314,6 @@ export async function cmdUI(args) {
350
314
  return;
351
315
  }
352
316
 
353
- // POST /api/email/mark-read — mark email as read
354
- if (method === 'POST' && pathname === '/api/email/mark-read') {
355
- const body = await parseBody(req);
356
- if (!body.messageId) {
357
- sendJSON(res, 400, { error: 'messageId required' });
358
- logRequest(method, pathname, 400, Date.now() - start);
359
- return;
360
- }
361
- try {
362
- const gmail = await import('../services/google-gmail.mjs');
363
- await gmail.markAsRead(config, body.messageId);
364
- sendJSON(res, 200, { ok: true });
365
- } catch (e) {
366
- sendJSON(res, 200, { ok: false, error: e.message });
367
- }
368
- logRequest(method, pathname, 200, Date.now() - start);
369
- return;
370
- }
371
-
372
- // POST /api/email/mark-all-read — mark ALL unread as read
373
- if (method === 'POST' && pathname === '/api/email/mark-all-read') {
374
- try {
375
- const gmail = await import('../services/google-gmail.mjs');
376
- const result = await gmail.markAllAsRead(config);
377
- // Update local cache
378
- dash.emails.forEach(e => { e.isUnread = false; });
379
- sendJSON(res, 200, { ok: true, count: result.count });
380
- } catch (e) {
381
- sendJSON(res, 200, { ok: false, error: e.message });
382
- }
383
- logRequest(method, pathname, 200, Date.now() - start);
384
- return;
385
- }
386
-
387
317
  // POST /api/contacts — create contact
388
318
  if (method === 'POST' && pathname === '/api/contacts') {
389
319
  try {
@@ -586,50 +516,28 @@ export async function cmdUI(args) {
586
516
  return;
587
517
  }
588
518
 
589
- // GET /api/emails?page=0&pageSize=25&filter=unread|all
519
+ // GET /api/emails?filter=unread|all (default: all inbox)
590
520
  if (method === 'GET' && pathname === '/api/emails') {
591
521
  try {
592
522
  const filter = url.searchParams.get('filter');
593
- const page = parseInt(url.searchParams.get('page') || '0', 10);
594
- const pageSize = parseInt(url.searchParams.get('pageSize') || '25', 10);
595
-
523
+ let emails;
596
524
  if (filter === 'unread') {
597
- const emails = await getUnreadImportant(config, pageSize);
598
- sendJSON(res, 200, { emails, page, hasMore: false });
525
+ emails = await getUnreadImportant(config, 20);
599
526
  } else {
527
+ // Show all recent inbox emails (read + unread)
600
528
  const gm = await import('../services/google-gmail.mjs');
601
- // Fetch more refs than needed so we know if there are more pages
602
- const totalToFetch = (page + 1) * pageSize + 1;
603
- const msgRefs = await gm.listMessages(config, 'in:inbox', totalToFetch);
604
-
605
- // Slice for current page
606
- const pageRefs = msgRefs.slice(page * pageSize, (page + 1) * pageSize);
607
- const hasMore = msgRefs.length > (page + 1) * pageSize;
608
-
609
- // Fetch message details (parallel, batches of 5 for speed)
610
- const emails = [];
611
- for (let i = 0; i < pageRefs.length; i += 5) {
612
- const batch = pageRefs.slice(i, i + 5);
613
- const results = await Promise.allSettled(
614
- batch.map(ref => gm.getMessage(config, ref.id))
615
- );
616
- for (const r of results) {
617
- if (r.status === 'fulfilled') emails.push(r.value);
618
- }
619
- }
620
-
621
- // Cache emails in memory for the session
622
- if (!config._emailCache) config._emailCache = [];
623
- for (const em of emails) {
624
- if (!config._emailCache.find(c => c.id === em.id)) {
625
- config._emailCache.push(em);
626
- }
529
+ const msgRefs = await gm.listMessages(config, 'in:inbox', 30);
530
+ emails = [];
531
+ for (const ref of msgRefs.slice(0, 30)) {
532
+ try {
533
+ const msg = await gm.getMessage(config, ref.id);
534
+ emails.push(msg);
535
+ } catch { /* skip */ }
627
536
  }
628
-
629
- sendJSON(res, 200, { emails, page, hasMore, totalCached: config._emailCache?.length || 0 });
630
537
  }
538
+ sendJSON(res, 200, { emails });
631
539
  } catch (e) {
632
- sendJSON(res, 200, { emails: [], error: e.message, page: 0, hasMore: false });
540
+ sendJSON(res, 200, { emails: [], error: e.message });
633
541
  }
634
542
  logRequest(method, pathname, 200, Date.now() - start);
635
543
  return;
@@ -742,310 +650,29 @@ export async function cmdUI(args) {
742
650
  return;
743
651
  }
744
652
 
745
- const msg = body.message.trim();
746
-
747
- // ── Slash commands ───────────────────────────────────────
748
- if (msg === '/agents') {
749
- const custom = agentCards.filter(a => a.category === 'custom').map(a => a.name);
750
- const builtIn = agentCards.filter(a => a.category !== 'custom').map(a => a.name);
751
- sendJSON(res, 200, { response: `**Available agents (${agentCards.length}):**\n\nBuilt-in: ${builtIn.join(', ')}\n${custom.length ? `\nCustom: ${custom.join(', ')}` : ''}\n\nUse \`@agent your message\` to route to a specific agent.\nUse \`/agent <name>\` to switch all messages.\nUse \`/agent off\` to return to NHA Chat.` });
752
- logRequest(method, pathname, 200, Date.now() - start);
753
- return;
754
- }
755
-
756
- if (msg.startsWith('/agent ')) {
757
- const agentName = msg.slice(7).trim().toLowerCase();
758
- if (agentName === 'off' || agentName === 'reset') {
759
- config._chatAgent = null;
760
- sendJSON(res, 200, { response: 'Switched back to NHA Chat.' });
761
- } else {
762
- const found = agentCards.find(a => a.name === agentName);
763
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
764
- let sysPrompt = `You are the ${agentName} AI agent. Be expert and helpful.`;
765
- if (fs.existsSync(agentFile)) {
766
- const src = fs.readFileSync(agentFile, 'utf-8');
767
- const parsed = parseAgentFile(src, agentName);
768
- if (parsed.systemPrompt) sysPrompt = parsed.systemPrompt;
769
- }
770
- config._chatAgent = { name: agentName, systemPrompt: sysPrompt };
771
- sendJSON(res, 200, { response: `Now chatting with **${agentName.toUpperCase()}**${found ? ` (${found.tagline})` : ''}.\nAll messages will be routed to this agent.\nType \`/agent off\` to return to NHA Chat.` });
772
- }
773
- logRequest(method, pathname, 200, Date.now() - start);
774
- return;
775
- }
776
-
777
- if (msg === '/create-agent' || msg.startsWith('/create-agent ')) {
778
- const parts = msg.slice(14).trim();
779
- if (!parts) {
780
- sendJSON(res, 200, { response: '**Create Custom Agent**\n\nUsage:\n```\n/create-agent mybot "Short description" "You are an expert in..."\n```\n\nExample:\n```\n/create-agent chef "Italian cooking expert" "You are a master Italian chef. Always suggest authentic recipes."\n```' });
781
- logRequest(method, pathname, 200, Date.now() - start);
782
- return;
783
- }
784
- const nameMatch = parts.match(/^(\S+)\s+(.*)/s);
785
- if (!nameMatch) {
786
- sendJSON(res, 200, { response: 'Usage: `/create-agent <name> "<tagline>" "<system prompt>"`' });
787
- logRequest(method, pathname, 200, Date.now() - start);
788
- return;
789
- }
790
- const agentName = nameMatch[1].toLowerCase().replace(/[^a-z0-9_-]/g, '');
791
- const rest = nameMatch[2];
792
- const quoteParts = rest.match(/"([^"]*)"/g);
793
- let tagline = '', sysPrompt = '';
794
- if (quoteParts && quoteParts.length >= 2) {
795
- tagline = quoteParts[0].replace(/"/g, '');
796
- sysPrompt = quoteParts[1].replace(/"/g, '');
797
- } else {
798
- tagline = rest.replace(/"/g, '').trim();
799
- sysPrompt = tagline;
800
- }
801
- if (!agentName || !tagline) {
802
- sendJSON(res, 200, { response: 'All fields required. Usage: `/create-agent name "tagline" "system prompt"`' });
803
- logRequest(method, pathname, 200, Date.now() - start);
804
- return;
805
- }
806
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
807
- if (fs.existsSync(agentFile)) {
808
- sendJSON(res, 200, { response: `Agent "${agentName}" already exists. Delete it first with \`/delete-agent ${agentName}\`` });
809
- logRequest(method, pathname, 200, Date.now() - start);
810
- return;
811
- }
812
- const content = `// NHA Custom Agent: ${agentName}\n// Created: ${new Date().toISOString()}\n\nexport const CARD = {\n name: '${agentName}',\n displayName: '${agentName.toUpperCase()}',\n category: 'custom',\n tagline: '${tagline.replace(/'/g, "\\'")}',\n};\n\nexport const SYSTEM_PROMPT = \`${sysPrompt.replace(/`/g, '\\`')}\`;\n`;
813
- if (!fs.existsSync(AGENTS_DIR)) fs.mkdirSync(AGENTS_DIR, { recursive: true });
814
- fs.writeFileSync(agentFile, content, 'utf-8');
815
- // Reload agent cards
816
- agentCards.push({ name: agentName, displayName: agentName.toUpperCase(), category: 'custom', tagline });
817
- sendJSON(res, 200, { response: `Agent **${agentName.toUpperCase()}** created!\n\nSwitch to it: \`/agent ${agentName}\`\nOr use inline: \`@${agentName} your question\`` });
818
- logRequest(method, pathname, 200, Date.now() - start);
819
- return;
820
- }
821
-
822
- if (msg.startsWith('/delete-agent ')) {
823
- const agentName = msg.slice(14).trim().toLowerCase();
824
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
825
- if (!fs.existsSync(agentFile)) {
826
- sendJSON(res, 200, { response: `Agent "${agentName}" not found.` });
827
- } else {
828
- fs.unlinkSync(agentFile);
829
- const idx = agentCards.findIndex(a => a.name === agentName);
830
- if (idx >= 0) agentCards.splice(idx, 1);
831
- sendJSON(res, 200, { response: `Agent "${agentName}" deleted.` });
832
- }
833
- logRequest(method, pathname, 200, Date.now() - start);
834
- return;
835
- }
836
-
837
- if (msg === '/help') {
838
- sendJSON(res, 200, { response: '**Chat Commands**\n\n`/agents` — List all agents\n`/agent <name>` — Switch chat to agent\n`/agent off` — Return to NHA Chat\n`/create-agent name "tagline" "prompt"` — Create custom agent\n`/delete-agent name` — Delete agent\n`@agent message` — Route single message to agent\n`/help` — Show this help' });
839
- logRequest(method, pathname, 200, Date.now() - start);
840
- return;
841
- }
842
-
843
- // ── Direct intent handlers (bypass LLM for reliability) ──
844
- const msgLower = msg.toLowerCase();
845
-
846
- // Mark all emails as read
847
- if (msgLower.match(/segna.*tutt.*lett|mark.*all.*read|tutte.*lett[ae]|read.*all.*email|segna.*email.*lett/)) {
848
- try {
849
- const gmail = await import('../services/google-gmail.mjs');
850
- const result = await gmail.markAllAsRead(config);
851
- const count = result.count || 0;
852
- sendJSON(res, 200, { response: count > 0 ? `Done! ${count} email${count !== 1 ? 's' : ''} marked as read.` : 'All emails are already read.', toolResults: [{ action: 'gmail_mark_read', result: `${count} marked` }] });
853
- } catch (e) {
854
- sendJSON(res, 200, { response: `Error marking emails as read: ${e.message}` });
855
- }
856
- logRequest(method, pathname, 200, Date.now() - start);
857
- return;
858
- }
859
-
860
- // ── @agent inline routing ────────────────────────────────
861
- let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
862
- const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
863
- if (atMatch) {
864
- const inlineAgent = atMatch[1].toLowerCase();
865
- body.message = atMatch[2];
866
- const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
867
- if (fs.existsSync(agentFile)) {
868
- const src = fs.readFileSync(agentFile, 'utf-8');
869
- const parsed = parseAgentFile(src, inlineAgent);
870
- if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
871
- } else {
872
- effectiveSystemPrompt = `You are the ${inlineAgent} AI agent. Be expert, concise, and helpful.`;
873
- }
874
- }
875
-
876
653
  if (!config.llm.apiKey) {
877
654
  sendJSON(res, 200, { response: 'No API key configured. Run: nha config set key YOUR_KEY', error: 'no_api_key' });
878
655
  logRequest(method, pathname, 200, Date.now() - start);
879
656
  return;
880
657
  }
881
658
 
882
- // Build message with rolling context (same strategy as streaming path)
883
- const requestHistory = (body.history || []).map(h => ({
884
- role: h.role,
885
- content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
886
- }));
887
- const RECENT = 6;
659
+ // Build message with history (merge persisted + request history)
660
+ const requestHistory = body.history || [];
888
661
  const parts = [];
889
- if (requestHistory.length > RECENT) {
890
- const older = requestHistory.slice(0, -RECENT);
891
- const sLines = [];
892
- for (let i = 0; i < older.length; i += 2) {
893
- const u = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
894
- const a = older[i + 1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
895
- if (u) sLines.push(`- User: "${u.trim()}${u.length >= 150 ? '...' : ''}" → ${a.trim()}${a.length >= 200 ? '...' : ''}`);
896
- }
897
- if (sLines.length > 0) parts.push(`[CONTEXT — ${sLines.length} earlier exchanges]\n${sLines.join('\n')}\n[END CONTEXT]`);
898
- }
899
- for (const turn of requestHistory.slice(-RECENT)) {
900
- parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turn.content.slice(0, 2000)}`);
662
+ for (const turn of requestHistory) {
663
+ const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
664
+ parts.push(`${prefix} ${turn.content}`);
901
665
  }
902
666
  parts.push(`[User] ${body.message}`);
903
- let userMessage = parts.join('\n\n');
667
+ const userMessage = parts.join('\n\n');
904
668
 
905
669
  // Inject episodic memory context into the system prompt
906
- const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
907
- let enrichedSystemPrompt = basePrompt;
670
+ let enrichedSystemPrompt = chatSystemPrompt;
908
671
  try {
909
672
  const memCtx = buildMemoryContext('chat', body.message);
910
- if (memCtx) enrichedSystemPrompt = basePrompt + memCtx;
673
+ if (memCtx) enrichedSystemPrompt = chatSystemPrompt + memCtx;
911
674
  } catch { /* memory unavailable */ }
912
675
 
913
- // Handle image attachment — vision API
914
- if (body.imageBase64 && body.imageMimeType) {
915
- try {
916
- const provider = config.llm.provider || 'anthropic';
917
- const apiKey = config.llm.apiKey;
918
- const model = config.llm.model;
919
- const imagePrompt = body.message || 'Describe this image in detail. Extract any text or important information.';
920
- let visionResponse = '';
921
-
922
- if (provider === 'anthropic') {
923
- const r = await fetch('https://api.anthropic.com/v1/messages', {
924
- method: 'POST',
925
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
926
- body: JSON.stringify({
927
- model: model || 'claude-sonnet-4-20250514', max_tokens: 4096, system: enrichedSystemPrompt,
928
- messages: [{ role: 'user', content: [
929
- { type: 'image', source: { type: 'base64', media_type: body.imageMimeType, data: body.imageBase64 } },
930
- { type: 'text', text: imagePrompt },
931
- ]}],
932
- }),
933
- });
934
- if (!r.ok) throw new Error(`Anthropic ${r.status}`);
935
- const d = await r.json();
936
- visionResponse = d.content?.[0]?.text || '';
937
- } else if (provider === 'openai') {
938
- const r = await fetch('https://api.openai.com/v1/chat/completions', {
939
- method: 'POST',
940
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
941
- body: JSON.stringify({
942
- model: model || 'gpt-4o-mini', max_tokens: 4096,
943
- messages: [
944
- { role: 'system', content: enrichedSystemPrompt },
945
- { role: 'user', content: [
946
- { type: 'image_url', image_url: { url: `data:${body.imageMimeType};base64,${body.imageBase64}` } },
947
- { type: 'text', text: imagePrompt },
948
- ]},
949
- ],
950
- }),
951
- });
952
- if (!r.ok) throw new Error(`OpenAI ${r.status}`);
953
- const d = await r.json();
954
- visionResponse = d.choices?.[0]?.message?.content || '';
955
- } else if (provider === 'gemini') {
956
- const m = model || 'gemini-2.0-flash';
957
- const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`, {
958
- method: 'POST',
959
- headers: { 'Content-Type': 'application/json' },
960
- body: JSON.stringify({
961
- system_instruction: { parts: [{ text: enrichedSystemPrompt }] },
962
- contents: [{ parts: [
963
- { inline_data: { mime_type: body.imageMimeType, data: body.imageBase64 } },
964
- { text: imagePrompt },
965
- ]}],
966
- generationConfig: { maxOutputTokens: 4096 },
967
- }),
968
- });
969
- if (!r.ok) throw new Error(`Gemini ${r.status}`);
970
- const d = await r.json();
971
- visionResponse = d.candidates?.[0]?.content?.parts?.[0]?.text || '';
972
- } else {
973
- visionResponse = `Vision not supported for provider "${provider}". Use anthropic, openai, or gemini.`;
974
- }
975
-
976
- sendJSON(res, 200, { response: visionResponse });
977
- logRequest(method, pathname, 200, Date.now() - start);
978
- return;
979
- } catch (e) {
980
- sendJSON(res, 200, { response: null, error: e.message });
981
- logRequest(method, pathname, 200, Date.now() - start);
982
- return;
983
- }
984
- }
985
-
986
- // Handle PDF attachment — send as document to Claude (native PDF support)
987
- if (body.pdfBase64 && body.pdfName) {
988
- try {
989
- const provider = config.llm.provider || 'anthropic';
990
- const apiKey = config.llm.apiKey;
991
- const model = config.llm.model;
992
- const pdfPrompt = body.message || `Read and analyze this PDF document "${body.pdfName}". Extract all text content, summarize key information.`;
993
- let pdfResponse = '';
994
-
995
- if (provider === 'anthropic') {
996
- const r = await fetch('https://api.anthropic.com/v1/messages', {
997
- method: 'POST',
998
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
999
- body: JSON.stringify({
1000
- model: model || 'claude-sonnet-4-20250514', max_tokens: 8192, system: enrichedSystemPrompt,
1001
- messages: [{ role: 'user', content: [
1002
- { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: body.pdfBase64 } },
1003
- { type: 'text', text: pdfPrompt },
1004
- ]}],
1005
- }),
1006
- });
1007
- if (!r.ok) throw new Error(`Anthropic ${r.status}: ${(await r.text()).slice(0, 200)}`);
1008
- const d = await r.json();
1009
- pdfResponse = d.content?.[0]?.text || '';
1010
- } else if (provider === 'gemini') {
1011
- const m = model || 'gemini-2.0-flash';
1012
- const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`, {
1013
- method: 'POST',
1014
- headers: { 'Content-Type': 'application/json' },
1015
- body: JSON.stringify({
1016
- system_instruction: { parts: [{ text: enrichedSystemPrompt }] },
1017
- contents: [{ parts: [
1018
- { inline_data: { mime_type: 'application/pdf', data: body.pdfBase64 } },
1019
- { text: pdfPrompt },
1020
- ]}],
1021
- generationConfig: { maxOutputTokens: 8192 },
1022
- }),
1023
- });
1024
- if (!r.ok) throw new Error(`Gemini ${r.status}`);
1025
- const d = await r.json();
1026
- pdfResponse = d.candidates?.[0]?.content?.parts?.[0]?.text || '';
1027
- } else {
1028
- pdfResponse = `PDF reading requires Anthropic (Claude) or Gemini. Your provider "${provider}" does not support native PDF documents.`;
1029
- }
1030
-
1031
- sendJSON(res, 200, { response: pdfResponse });
1032
- logRequest(method, pathname, 200, Date.now() - start);
1033
- return;
1034
- } catch (e) {
1035
- sendJSON(res, 200, { response: null, error: `PDF error: ${e.message}` });
1036
- logRequest(method, pathname, 200, Date.now() - start);
1037
- return;
1038
- }
1039
- }
1040
-
1041
- // Handle text file attachment
1042
- if (body.fileContent && body.fileName) {
1043
- const filePrompt = body.message
1044
- ? `User asks about file "${body.fileName}": ${body.message}\n\nFile content:\n${body.fileContent.slice(0, 8000)}`
1045
- : `Analyze this file "${body.fileName}":\n\n${body.fileContent.slice(0, 8000)}`;
1046
- userMessage = filePrompt;
1047
- }
1048
-
1049
676
  try {
1050
677
  const response = await callLLM(config, enrichedSystemPrompt, userMessage);
1051
678
  const { textParts, actions } = parseActions(response);
@@ -1053,27 +680,49 @@ export async function cmdUI(args) {
1053
680
 
1054
681
  // Execute ALL tool actions and collect results
1055
682
  const toolResults = [];
683
+ let screenshotData = null; // For vision: { base64, path, question }
684
+ let screenshotFiles = []; // For displaying inline
1056
685
  for (const { action, params } of actions) {
1057
686
  try {
1058
687
  const result = await executeTool(action, params, config);
1059
- toolResults.push({ action, result: typeof result === 'object' ? JSON.stringify(result) : String(result) });
688
+ // Check if result is a structured screenshot object
689
+ if (result && typeof result === 'object' && result.__screenshot) {
690
+ screenshotData = result;
691
+ screenshotFiles.push(result.path);
692
+ toolResults.push({ action, result: 'Screenshot captured. Analyzing with vision...' });
693
+ } else {
694
+ toolResults.push({ action, result: typeof result === 'object' ? JSON.stringify(result) : String(result) });
695
+ }
1060
696
  } catch (e) {
1061
697
  toolResults.push({ action, result: `Error: ${e.message}` });
1062
698
  }
1063
699
  }
1064
700
 
1065
701
  let fullResponse;
1066
- if (toolResults.length > 0) {
1067
- // Second LLM call with real tool results — forces the LLM to use actual data
1068
- const toolContext = toolResults.map(t => {
1069
- let clean = t.result.replace(/\[Screenshot[^\]]*\]/g, '').replace(/!\[.*?\]\(data:image[^)]+\)/g, '').slice(0, 3000);
1070
- return `[${t.action} result]: ${clean.trim()}`;
1071
- }).join('\n\n');
1072
- const followUp = `The user asked: "${body.message}"\n\nI executed these tools and got REAL results:\n\n${toolContext}\n\nNow respond conversationally based ONLY on the REAL data above. Do NOT output any JSON blocks, base64, or image markdown — just natural text.`;
702
+ if (screenshotData && screenshotData.base64) {
703
+ // VISION FLOW: send screenshot image to LLM as multimodal content
704
+ try {
705
+ const visionMessages = [
706
+ { role: 'system', content: enrichedSystemPrompt + '\n\nIMPORTANT: You are looking at a REAL screenshot from the user\'s screen. Describe ONLY what you ACTUALLY see. NEVER invent, guess, or fabricate details. If something is unclear, say so. Be specific about windows, text, UI elements you can identify.' },
707
+ { role: 'user', content: [
708
+ { type: 'image_url', image_url: { url: `data:image/png;base64,${screenshotData.base64}` } },
709
+ { type: 'text', text: `The user said: "${body.message}"\n\n${screenshotData.question}\n\nDescribe ONLY what you see. NEVER make up information.` },
710
+ ] },
711
+ ];
712
+ fullResponse = await callLLMVision(config, visionMessages);
713
+ } catch (visionErr) {
714
+ // Fallback: try regular call explaining we can't do vision
715
+ fullResponse = `I captured a screenshot but your current LLM provider doesn't support vision/image analysis. The screenshot is saved at: ${screenshotData.path}\n\nTo use screen analysis, configure a vision-capable provider (Claude, GPT-4, Gemini).`;
716
+ }
717
+ // Prepend screenshot file marker for the UI to display
718
+ fullResponse = `[SCREENSHOT_FILE]${screenshotData.path}[/SCREENSHOT_FILE]\n${fullResponse}`;
719
+ } else if (toolResults.length > 0) {
720
+ // Standard tool results flow
721
+ const toolContext = toolResults.map(t => `[${t.action} result]: ${t.result}`).join('\n\n');
722
+ const followUp = `The user asked: "${body.message}"\n\nI executed these tools and got REAL results:\n\n${toolContext}\n\nNow respond to the user based ONLY on the REAL data above. Do NOT invent or fabricate any information. Present the actual results clearly.`;
1073
723
  try {
1074
724
  fullResponse = await callLLM(config, enrichedSystemPrompt, followUp);
1075
725
  } catch {
1076
- // Fallback: show raw results
1077
726
  fullResponse = toolResults.map(t => `${t.action}: ${t.result}`).join('\n\n');
1078
727
  }
1079
728
  } else {
@@ -1089,7 +738,7 @@ export async function cmdUI(args) {
1089
738
  } catch { /* non-critical */ }
1090
739
  try { extractMemory('chat', body.message, fullResponse); } catch { /* non-critical */ }
1091
740
 
1092
- sendJSON(res, 200, { response: fullResponse, toolResults, actions });
741
+ sendJSON(res, 200, { response: fullResponse, toolResults, actions, screenshotFiles });
1093
742
  } catch (e) {
1094
743
  sendJSON(res, 200, { response: null, error: e.message });
1095
744
  }
@@ -1097,334 +746,6 @@ export async function cmdUI(args) {
1097
746
  return;
1098
747
  }
1099
748
 
1100
- // ── Conversations API ────────────────────────────────────────────
1101
-
1102
- // GET /api/conversations — list all
1103
- if (method === 'GET' && pathname === '/api/conversations') {
1104
- const convs = listConversations();
1105
- sendJSON(res, 200, { conversations: convs });
1106
- logRequest(method, pathname, 200, Date.now() - start);
1107
- return;
1108
- }
1109
-
1110
- // POST /api/conversations — create new
1111
- if (method === 'POST' && pathname === '/api/conversations') {
1112
- const conv = createConversation();
1113
- setActiveId(conv.id);
1114
- sendJSON(res, 201, { conversation: conv });
1115
- logRequest(method, pathname, 201, Date.now() - start);
1116
- return;
1117
- }
1118
-
1119
- // GET /api/conversations/:id
1120
- if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1121
- const id = pathname.split('/')[3];
1122
- const conv = loadConversation(id);
1123
- if (!conv) { sendJSON(res, 404, { error: 'Conversation not found' }); }
1124
- else { sendJSON(res, 200, { conversation: conv }); }
1125
- logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1126
- return;
1127
- }
1128
-
1129
- // DELETE /api/conversations/:id
1130
- if (method === 'DELETE' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1131
- const id = pathname.split('/')[3];
1132
- const ok = deleteConversation(id);
1133
- sendJSON(res, ok ? 200 : 404, { ok });
1134
- logRequest(method, pathname, ok ? 200 : 404, Date.now() - start);
1135
- return;
1136
- }
1137
-
1138
- // PATCH /api/conversations/:id — rename
1139
- if (method === 'PATCH' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1140
- const id = pathname.split('/')[3];
1141
- const body = await parseBody(req);
1142
- const conv = loadConversation(id);
1143
- if (!conv) { sendJSON(res, 404, { error: 'Not found' }); }
1144
- else {
1145
- if (body.title) conv.title = body.title;
1146
- saveConversation(conv);
1147
- sendJSON(res, 200, { conversation: conv });
1148
- }
1149
- logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1150
- return;
1151
- }
1152
-
1153
- // GET /api/conversations/:id/export?format=md|json
1154
- if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/export$/)) {
1155
- const id = pathname.split('/')[3];
1156
- const conv = loadConversation(id);
1157
- if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1158
- const url = new URL(req.url, `http://${req.headers.host}`);
1159
- const format = url.searchParams.get('format') || 'md';
1160
- if (format === 'json') {
1161
- const exported = exportAsJson(conv);
1162
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="nha-chat-${id}.json"` });
1163
- res.end(exported);
1164
- } else {
1165
- const exported = exportAsMarkdown(conv);
1166
- res.writeHead(200, { 'Content-Type': 'text/markdown', 'Content-Disposition': `attachment; filename="nha-chat-${id}.md"` });
1167
- res.end(exported);
1168
- }
1169
- logRequest(method, pathname, 200, Date.now() - start);
1170
- return;
1171
- }
1172
-
1173
- // ── Streaming Chat API ─────────────────────────────────────────────
1174
-
1175
- // POST /api/chat/stream — SSE streaming chat with conversation persistence
1176
- if (method === 'POST' && pathname === '/api/chat/stream') {
1177
- const body = await parseBody(req);
1178
- if (!body.message) { sendJSON(res, 400, { error: 'message required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1179
- if (!config.llm.apiKey) { sendJSON(res, 200, { error: 'no_api_key' }); logRequest(method, pathname, 200, Date.now() - start); return; }
1180
-
1181
- const msg = body.message.trim();
1182
- const convId = body.conversationId;
1183
-
1184
- // Build system prompt
1185
- let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
1186
- let effectiveMsg = msg;
1187
- const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
1188
- if (atMatch) {
1189
- const inlineAgent = atMatch[1].toLowerCase();
1190
- effectiveMsg = atMatch[2];
1191
- const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
1192
- if (fs.existsSync(agentFile)) {
1193
- const src = fs.readFileSync(agentFile, 'utf-8');
1194
- const parsed = parseAgentFile(src, inlineAgent);
1195
- if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
1196
- }
1197
- }
1198
-
1199
- const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
1200
- let enrichedPrompt = basePrompt;
1201
- try { const m = buildMemoryContext('chat', effectiveMsg); if (m) enrichedPrompt = basePrompt + m; } catch {}
1202
-
1203
- // Build message with rolling context window:
1204
- // - Recent messages (last 6): full content up to 2000 chars
1205
- // - Older messages: compressed to 1-line summaries preserving full context
1206
- const rawHistory = (body.history || []).map(h => ({
1207
- role: h.role,
1208
- content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
1209
- }));
1210
-
1211
- const RECENT_COUNT = 6;
1212
- const parts = [];
1213
-
1214
- if (rawHistory.length > RECENT_COUNT) {
1215
- // Compress older messages into a conversation summary
1216
- const older = rawHistory.slice(0, -RECENT_COUNT);
1217
- const summaryLines = [];
1218
- for (let i = 0; i < older.length; i += 2) {
1219
- const userMsg = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
1220
- const assistantMsg = older[i + 1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
1221
- if (userMsg) summaryLines.push(`- User asked: "${userMsg.trim()}${userMsg.length >= 150 ? '...' : ''}" → Assistant: ${assistantMsg.trim()}${assistantMsg.length >= 200 ? '...' : ''}`);
1222
- }
1223
- if (summaryLines.length > 0) {
1224
- parts.push(`[CONVERSATION CONTEXT — ${summaryLines.length} earlier exchanges]\n${summaryLines.join('\n')}\n[END CONTEXT]`);
1225
- }
1226
- }
1227
-
1228
- // Recent messages in full
1229
- const recent = rawHistory.slice(-RECENT_COUNT);
1230
- for (const turn of recent) {
1231
- const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
1232
- parts.push(`${prefix} ${turn.content.slice(0, 2000)}`);
1233
- }
1234
-
1235
- parts.push(`[User] ${effectiveMsg}`);
1236
- const userMessage = parts.join('\n\n');
1237
-
1238
- // Handle file/image/pdf attachments — fall back to non-streaming
1239
- if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
1240
- // Redirect to regular /api/chat for attachment handling
1241
- sendJSON(res, 200, { error: 'attachments_use_regular', redirect: '/api/chat' });
1242
- logRequest(method, pathname, 200, Date.now() - start);
1243
- return;
1244
- }
1245
-
1246
- // SSE headers
1247
- res.writeHead(200, {
1248
- 'Content-Type': 'text/event-stream',
1249
- 'Cache-Control': 'no-cache',
1250
- 'Connection': 'keep-alive',
1251
- 'Access-Control-Allow-Origin': '*',
1252
- });
1253
-
1254
- const sendSSE = (event, data) => {
1255
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1256
- };
1257
-
1258
- sendSSE('processing', {});
1259
-
1260
- try {
1261
- let fullResponse = '';
1262
- fullResponse = await callLLMStream(config, enrichedPrompt, userMessage, (chunk) => {
1263
- sendSSE('token', { content: chunk });
1264
- });
1265
-
1266
- // Parse and execute tools
1267
- const { textParts, actions } = parseActions(fullResponse);
1268
- const toolResults = [];
1269
-
1270
- // Auto-detect search + screenshot intent from user message
1271
- const wantsScreenshot = /screenshot|screen\s*shot|schermo|cattura|foto|immagine/i.test(msg);
1272
- const wantsSearch = /\b(cerca|search|find|look\s*up|ricerca|cercare)\b/i.test(msg);
1273
-
1274
- // If user asked to search but LLM didn't call web_search, force it
1275
- if (wantsSearch && !actions.some(a => a.action === 'web_search')) {
1276
- // Extract search query from message (remove action words)
1277
- const searchQuery = msg.replace(/\b(cerca|search|find|look\s*up|ricerca|cercare|e\s+fai|and\s+take|screenshot|screen\s*shot|schermo|cattura|foto|immagine|dei|dei\s+risultati|of\s+the\s+results|risultati|results)\b/gi, '').replace(/["""]/g, '').trim();
1278
- if (searchQuery.length > 2) {
1279
- actions.push({ action: 'web_search', params: { query: searchQuery, screenshot: wantsScreenshot } });
1280
- }
1281
- }
1282
-
1283
- for (const { action, params } of actions) {
1284
- // Force screenshot=true on web_search if user asked for screenshot
1285
- if (action === 'web_search' && wantsScreenshot && !params.screenshot) {
1286
- params.screenshot = true;
1287
- }
1288
-
1289
- sendSSE('tool', { action, status: 'executing' });
1290
- try {
1291
- // For browser_screenshot in web UI: capture and send base64 image
1292
- if (action === 'browser_screenshot') {
1293
- const be = await import('../services/browser-engine.mjs');
1294
- if (!be.isBrowserRunning()) {
1295
- toolResults.push({ action, result: 'No browser open. Use browser_open first.' });
1296
- sendSSE('tool', { action, status: 'error', error: 'No browser open' });
1297
- continue;
1298
- }
1299
- // Scroll to top for best viewport
1300
- await be.browserScroll({ direction: 'top' });
1301
- await new Promise(r => setTimeout(r, 300));
1302
- const ssResult = await be.browserScreenshot({
1303
- fullPage: false, // Always viewport
1304
- format: 'jpeg',
1305
- quality: 75,
1306
- });
1307
- if (!ssResult.error) {
1308
- // Save screenshot to disk for persistence across sessions
1309
- const ssDir = path.join(NHA_DIR, 'screenshots');
1310
- fs.mkdirSync(ssDir, { recursive: true });
1311
- const ssFilename = `ss-${Date.now()}.jpg`;
1312
- fs.writeFileSync(path.join(ssDir, ssFilename), Buffer.from(ssResult.base64, 'base64'));
1313
-
1314
- sendSSE('screenshot', { base64: ssResult.base64, format: 'jpeg', filename: ssFilename });
1315
- toolResults.push({ action, result: `Screenshot captured (${Math.round(ssResult.size / 1024)}KB) [file: ${ssFilename}]` });
1316
- // Store screenshot ref for persistence
1317
- if (!res._screenshotFiles) res._screenshotFiles = [];
1318
- res._screenshotFiles.push(ssFilename);
1319
- sendSSE('tool', { action, status: 'done', result: 'Screenshot captured' });
1320
- } else {
1321
- toolResults.push({ action, result: `Error: ${ssResult.message}` });
1322
- sendSSE('tool', { action, status: 'error', error: ssResult.message });
1323
- }
1324
- continue;
1325
- }
1326
-
1327
- const result = await executeTool(action, params, config);
1328
- const resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
1329
- toolResults.push({ action, result: resultStr });
1330
- sendSSE('tool', { action, status: 'done', result: typeof resultStr === 'string' ? resultStr.slice(0, 500) : '' });
1331
-
1332
- // Send live browser frame after browser actions (low-quality thumbnail for viewer)
1333
- if (action.startsWith('browser_') && action !== 'browser_close') {
1334
- try {
1335
- const be = await import('../services/browser-engine.mjs');
1336
- if (be.isBrowserRunning()) {
1337
- const frame = await be.browserScreenshot({ fullPage: false, format: 'jpeg', quality: 30 });
1338
- if (!frame.error) {
1339
- const info = await be.browserInfo();
1340
- sendSSE('browser_frame', { base64: frame.base64, format: 'jpeg', url: (info.url || '').slice(0, 80) });
1341
- }
1342
- }
1343
- } catch { /* frame capture failed, non-critical */ }
1344
- }
1345
-
1346
- // If the tool produced a screenshot (web_search with screenshot=true), send it via SSE
1347
- if (resultStr.includes('[Screenshot of results captured')) {
1348
- try {
1349
- const fileMatch = resultStr.match(/file:(ss-\d+\.jpg)/);
1350
- console.log(` [screenshot] file match: ${fileMatch?.[1] || 'NONE'}`);
1351
- if (fileMatch) {
1352
- const ssFilename = fileMatch[1];
1353
- const ssPath = path.join(NHA_DIR, 'screenshots', ssFilename);
1354
- const exists = fs.existsSync(ssPath);
1355
- console.log(` [screenshot] path: ${ssPath}, exists: ${exists}`);
1356
- if (exists) {
1357
- const ssBase64 = fs.readFileSync(ssPath).toString('base64');
1358
- console.log(` [screenshot] sending SSE, base64 size: ${ssBase64.length}`);
1359
- sendSSE('screenshot', { base64: ssBase64, format: 'jpeg', filename: ssFilename });
1360
- sendSSE('browser_frame', { base64: ssBase64, format: 'jpeg', url: 'Search results' });
1361
- if (!res._screenshotFiles) res._screenshotFiles = [];
1362
- res._screenshotFiles.push(ssFilename);
1363
- }
1364
- }
1365
- } catch (ssErr) { console.log(` [screenshot] ERROR: ${ssErr.message}`); }
1366
- }
1367
- } catch (e) {
1368
- toolResults.push({ action, result: `Error: ${e.message}` });
1369
- sendSSE('tool', { action, status: 'error', error: e.message });
1370
- }
1371
- }
1372
-
1373
- // If tools were executed, make a second LLM call with results
1374
- let finalResponse = fullResponse;
1375
- if (toolResults.length > 0) {
1376
- const toolContext = toolResults.map(t => {
1377
- // Strip screenshot file references and base64 from tool results — the screenshot was already sent to the UI
1378
- let clean = t.result.replace(/\[Screenshot[^\]]*\]/g, '').replace(/!\[.*?\]\(data:image[^)]+\)/g, '').slice(0, 3000);
1379
- return `[${t.action} result]: ${clean.trim()}`;
1380
- }).join('\n\n');
1381
- const followUp = `The user asked: "${msg}"\n\nI executed these tools and got REAL results:\n\n${toolContext}\n\nNow respond to the user conversationally based ONLY on the REAL data above. Present the results clearly. Do NOT output any JSON blocks, any base64 data, or any image markdown — just natural text. If a screenshot was taken, just mention "Screenshot captured" without embedding it.`;
1382
- sendSSE('tool_synthesis', {});
1383
- try {
1384
- finalResponse = await callLLMStream(config, enrichedPrompt, followUp, (chunk) => {
1385
- sendSSE('token', { content: chunk });
1386
- });
1387
- // Strip any JSON blocks and base64 the LLM might have emitted
1388
- finalResponse = finalResponse
1389
- .replace(/```json[\s\S]*?```/g, '')
1390
- .replace(/!\[.*?\]\(data:image\/[^)]+\)/g, '')
1391
- .replace(/data:image\/[a-z]+;base64,[A-Za-z0-9+/=]{100,}/g, '[image]')
1392
- .trim();
1393
- } catch {
1394
- finalResponse = toolResults.map(t => `${t.action}: ${t.result}`).join('\n\n');
1395
- }
1396
- }
1397
-
1398
- // Persist to conversation (append screenshot references so they survive reload)
1399
- if (convId) {
1400
- try {
1401
- let persistedResponse = finalResponse;
1402
- const ssFiles = res._screenshotFiles || [];
1403
- if (ssFiles.length > 0) {
1404
- const ssRefs = ssFiles.map(f => `\n![Screenshot](/api/screenshots/${f})`).join('');
1405
- persistedResponse = finalResponse + ssRefs;
1406
- }
1407
- const conv = loadConversation(convId);
1408
- if (conv) {
1409
- addMessages(conv, msg, persistedResponse);
1410
- }
1411
- } catch {}
1412
- }
1413
-
1414
- // Extract memory
1415
- try { extractMemory('chat', msg, finalResponse); } catch {}
1416
-
1417
- const ssFiles = res._screenshotFiles || [];
1418
- sendSSE('done', { content: finalResponse, screenshotFiles: ssFiles });
1419
- } catch (e) {
1420
- sendSSE('error', { message: e.message });
1421
- }
1422
-
1423
- res.end();
1424
- logRequest(method, pathname, 200, Date.now() - start);
1425
- return;
1426
- }
1427
-
1428
749
  // GET /api/agents
1429
750
  if (method === 'GET' && pathname === '/api/agents') {
1430
751
  sendJSON(res, 200, { agents: agentCards });
@@ -1432,91 +753,6 @@ export async function cmdUI(args) {
1432
753
  return;
1433
754
  }
1434
755
 
1435
- // POST /api/agents — create custom agent
1436
- if (method === 'POST' && pathname === '/api/agents') {
1437
- const body = await parseBody(req);
1438
- const name = (body.name || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
1439
- const tagline = body.tagline || '';
1440
- const systemPrompt = body.systemPrompt || '';
1441
- if (!name || !tagline || !systemPrompt) {
1442
- sendJSON(res, 400, { error: 'name, tagline, and systemPrompt required' });
1443
- logRequest(method, pathname, 400, Date.now() - start);
1444
- return;
1445
- }
1446
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1447
- const content = `// NHA Custom Agent: ${name}\n// Created: ${new Date().toISOString()}\n\nexport const CARD = {\n name: '${name}',\n displayName: '${name.toUpperCase()}',\n category: 'custom',\n tagline: '${tagline.replace(/'/g, "\\'")}',\n};\n\nexport const SYSTEM_PROMPT = \`${systemPrompt.replace(/`/g, '\\`')}\`;\n`;
1448
- if (!fs.existsSync(AGENTS_DIR)) fs.mkdirSync(AGENTS_DIR, { recursive: true });
1449
- fs.writeFileSync(agentFile, content, 'utf-8');
1450
- const existingIdx = agentCards.findIndex(a => a.name === name);
1451
- if (existingIdx >= 0) {
1452
- agentCards[existingIdx] = { name, displayName: name.toUpperCase(), category: 'custom', tagline };
1453
- } else {
1454
- agentCards.push({ name, displayName: name.toUpperCase(), category: 'custom', tagline });
1455
- }
1456
- sendJSON(res, 201, { ok: true, agent: { name, category: 'custom', tagline } });
1457
- logRequest(method, pathname, 201, Date.now() - start);
1458
- return;
1459
- }
1460
-
1461
- // PUT /api/agents/:name — edit agent
1462
- if (method === 'PUT' && pathname.startsWith('/api/agents/')) {
1463
- const name = pathname.split('/')[3];
1464
- const body = await parseBody(req);
1465
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1466
- if (!fs.existsSync(agentFile)) {
1467
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1468
- logRequest(method, pathname, 404, Date.now() - start);
1469
- return;
1470
- }
1471
- const tagline = body.tagline || '';
1472
- const systemPrompt = body.systemPrompt || '';
1473
- if (!tagline || !systemPrompt) {
1474
- sendJSON(res, 400, { error: 'tagline and systemPrompt required' });
1475
- logRequest(method, pathname, 400, Date.now() - start);
1476
- return;
1477
- }
1478
- const content = `// NHA Custom Agent: ${name}\n// Updated: ${new Date().toISOString()}\n\nexport const CARD = {\n name: '${name}',\n displayName: '${name.toUpperCase()}',\n category: '${body.category || 'custom'}',\n tagline: '${tagline.replace(/'/g, "\\'")}',\n};\n\nexport const SYSTEM_PROMPT = \`${systemPrompt.replace(/`/g, '\\`')}\`;\n`;
1479
- fs.writeFileSync(agentFile, content, 'utf-8');
1480
- const idx = agentCards.findIndex(a => a.name === name);
1481
- if (idx >= 0) { agentCards[idx].tagline = tagline; }
1482
- sendJSON(res, 200, { ok: true });
1483
- logRequest(method, pathname, 200, Date.now() - start);
1484
- return;
1485
- }
1486
-
1487
- // DELETE /api/agents/:name — delete agent
1488
- if (method === 'DELETE' && pathname.startsWith('/api/agents/')) {
1489
- const name = pathname.split('/')[3];
1490
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1491
- if (!fs.existsSync(agentFile)) {
1492
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1493
- logRequest(method, pathname, 404, Date.now() - start);
1494
- return;
1495
- }
1496
- fs.unlinkSync(agentFile);
1497
- const idx = agentCards.findIndex(a => a.name === name);
1498
- if (idx >= 0) agentCards.splice(idx, 1);
1499
- sendJSON(res, 200, { ok: true });
1500
- logRequest(method, pathname, 200, Date.now() - start);
1501
- return;
1502
- }
1503
-
1504
- // GET /api/agents/:name — get agent details (system prompt)
1505
- if (method === 'GET' && pathname.startsWith('/api/agents/') && pathname.split('/').length === 4) {
1506
- const name = pathname.split('/')[3];
1507
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1508
- if (!fs.existsSync(agentFile)) {
1509
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1510
- logRequest(method, pathname, 404, Date.now() - start);
1511
- return;
1512
- }
1513
- const src = fs.readFileSync(agentFile, 'utf-8');
1514
- const parsed = parseAgentFile(src, name);
1515
- sendJSON(res, 200, { name, category: parsed.card?.category || 'custom', tagline: parsed.card?.tagline || '', systemPrompt: parsed.systemPrompt || '' });
1516
- logRequest(method, pathname, 200, Date.now() - start);
1517
- return;
1518
- }
1519
-
1520
756
  // POST /api/ask — agent call with personal context (email, calendar, tasks)
1521
757
  if (method === 'POST' && pathname === '/api/ask') {
1522
758
  const body = await parseBody(req);
@@ -1532,9 +768,7 @@ export async function cmdUI(args) {
1532
768
  return;
1533
769
  }
1534
770
 
1535
- // Allow both built-in and custom agents
1536
- const agentFile = path.join(AGENTS_DIR, `${body.agent}.mjs`);
1537
- if (!AGENTS.includes(body.agent) && !fs.existsSync(agentFile)) {
771
+ if (!AGENTS.includes(body.agent)) {
1538
772
  sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
1539
773
  logRequest(method, pathname, 400, Date.now() - start);
1540
774
  return;