nothumanallowed 9.5.1 → 9.6.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, 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
 
@@ -190,7 +173,7 @@ export async function cmdUI(args) {
190
173
  if (method === 'OPTIONS') {
191
174
  res.writeHead(204, {
192
175
  'Access-Control-Allow-Origin': '*',
193
- 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
176
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
194
177
  'Access-Control-Allow-Headers': 'Content-Type',
195
178
  });
196
179
  res.end();
@@ -233,46 +216,22 @@ export async function cmdUI(args) {
233
216
 
234
217
  // ── API Routes ────────────────────────────────────────────────────
235
218
 
236
- // GET /api/screenshots/:filename — serve saved screenshots from disk
219
+ // GET /api/screenshots/:filename — serve screenshot files
237
220
  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 });
221
+ const filename = decodeURIComponent(pathname.split('/').pop());
222
+ // Prevent path traversal
223
+ if (filename.includes('..') || filename.includes('/')) {
224
+ res.writeHead(400); res.end('Bad request'); return;
225
+ }
226
+ const os = await import('os');
227
+ const screenshotPath = path.join(os.default.tmpdir(), 'nha-screenshots', filename);
228
+ if (fs.existsSync(screenshotPath)) {
229
+ const data = fs.readFileSync(screenshotPath);
230
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });
231
+ res.end(data);
232
+ } else {
233
+ res.writeHead(404); res.end('Not found');
267
234
  }
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
235
  return;
277
236
  }
278
237
 
@@ -350,40 +309,6 @@ export async function cmdUI(args) {
350
309
  return;
351
310
  }
352
311
 
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
312
  // POST /api/contacts — create contact
388
313
  if (method === 'POST' && pathname === '/api/contacts') {
389
314
  try {
@@ -596,9 +521,9 @@ export async function cmdUI(args) {
596
521
  } else {
597
522
  // Show all recent inbox emails (read + unread)
598
523
  const gm = await import('../services/google-gmail.mjs');
599
- const msgRefs = await gm.listMessages(config, 'in:inbox', 50);
524
+ const msgRefs = await gm.listMessages(config, 'in:inbox', 30);
600
525
  emails = [];
601
- for (const ref of msgRefs.slice(0, 50)) {
526
+ for (const ref of msgRefs.slice(0, 30)) {
602
527
  try {
603
528
  const msg = await gm.getMessage(config, ref.id);
604
529
  emails.push(msg);
@@ -720,310 +645,29 @@ export async function cmdUI(args) {
720
645
  return;
721
646
  }
722
647
 
723
- const msg = body.message.trim();
724
-
725
- // ── Slash commands ───────────────────────────────────────
726
- if (msg === '/agents') {
727
- const custom = agentCards.filter(a => a.category === 'custom').map(a => a.name);
728
- const builtIn = agentCards.filter(a => a.category !== 'custom').map(a => a.name);
729
- 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.` });
730
- logRequest(method, pathname, 200, Date.now() - start);
731
- return;
732
- }
733
-
734
- if (msg.startsWith('/agent ')) {
735
- const agentName = msg.slice(7).trim().toLowerCase();
736
- if (agentName === 'off' || agentName === 'reset') {
737
- config._chatAgent = null;
738
- sendJSON(res, 200, { response: 'Switched back to NHA Chat.' });
739
- } else {
740
- const found = agentCards.find(a => a.name === agentName);
741
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
742
- let sysPrompt = `You are the ${agentName} AI agent. Be expert and helpful.`;
743
- if (fs.existsSync(agentFile)) {
744
- const src = fs.readFileSync(agentFile, 'utf-8');
745
- const parsed = parseAgentFile(src, agentName);
746
- if (parsed.systemPrompt) sysPrompt = parsed.systemPrompt;
747
- }
748
- config._chatAgent = { name: agentName, systemPrompt: sysPrompt };
749
- 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.` });
750
- }
751
- logRequest(method, pathname, 200, Date.now() - start);
752
- return;
753
- }
754
-
755
- if (msg === '/create-agent' || msg.startsWith('/create-agent ')) {
756
- const parts = msg.slice(14).trim();
757
- if (!parts) {
758
- 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```' });
759
- logRequest(method, pathname, 200, Date.now() - start);
760
- return;
761
- }
762
- const nameMatch = parts.match(/^(\S+)\s+(.*)/s);
763
- if (!nameMatch) {
764
- sendJSON(res, 200, { response: 'Usage: `/create-agent <name> "<tagline>" "<system prompt>"`' });
765
- logRequest(method, pathname, 200, Date.now() - start);
766
- return;
767
- }
768
- const agentName = nameMatch[1].toLowerCase().replace(/[^a-z0-9_-]/g, '');
769
- const rest = nameMatch[2];
770
- const quoteParts = rest.match(/"([^"]*)"/g);
771
- let tagline = '', sysPrompt = '';
772
- if (quoteParts && quoteParts.length >= 2) {
773
- tagline = quoteParts[0].replace(/"/g, '');
774
- sysPrompt = quoteParts[1].replace(/"/g, '');
775
- } else {
776
- tagline = rest.replace(/"/g, '').trim();
777
- sysPrompt = tagline;
778
- }
779
- if (!agentName || !tagline) {
780
- sendJSON(res, 200, { response: 'All fields required. Usage: `/create-agent name "tagline" "system prompt"`' });
781
- logRequest(method, pathname, 200, Date.now() - start);
782
- return;
783
- }
784
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
785
- if (fs.existsSync(agentFile)) {
786
- sendJSON(res, 200, { response: `Agent "${agentName}" already exists. Delete it first with \`/delete-agent ${agentName}\`` });
787
- logRequest(method, pathname, 200, Date.now() - start);
788
- return;
789
- }
790
- 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`;
791
- if (!fs.existsSync(AGENTS_DIR)) fs.mkdirSync(AGENTS_DIR, { recursive: true });
792
- fs.writeFileSync(agentFile, content, 'utf-8');
793
- // Reload agent cards
794
- agentCards.push({ name: agentName, displayName: agentName.toUpperCase(), category: 'custom', tagline });
795
- sendJSON(res, 200, { response: `Agent **${agentName.toUpperCase()}** created!\n\nSwitch to it: \`/agent ${agentName}\`\nOr use inline: \`@${agentName} your question\`` });
796
- logRequest(method, pathname, 200, Date.now() - start);
797
- return;
798
- }
799
-
800
- if (msg.startsWith('/delete-agent ')) {
801
- const agentName = msg.slice(14).trim().toLowerCase();
802
- const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
803
- if (!fs.existsSync(agentFile)) {
804
- sendJSON(res, 200, { response: `Agent "${agentName}" not found.` });
805
- } else {
806
- fs.unlinkSync(agentFile);
807
- const idx = agentCards.findIndex(a => a.name === agentName);
808
- if (idx >= 0) agentCards.splice(idx, 1);
809
- sendJSON(res, 200, { response: `Agent "${agentName}" deleted.` });
810
- }
811
- logRequest(method, pathname, 200, Date.now() - start);
812
- return;
813
- }
814
-
815
- if (msg === '/help') {
816
- 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' });
817
- logRequest(method, pathname, 200, Date.now() - start);
818
- return;
819
- }
820
-
821
- // ── Direct intent handlers (bypass LLM for reliability) ──
822
- const msgLower = msg.toLowerCase();
823
-
824
- // Mark all emails as read
825
- if (msgLower.match(/segna.*tutt.*lett|mark.*all.*read|tutte.*lett[ae]|read.*all.*email|segna.*email.*lett/)) {
826
- try {
827
- const gmail = await import('../services/google-gmail.mjs');
828
- const result = await gmail.markAllAsRead(config);
829
- const count = result.count || 0;
830
- 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` }] });
831
- } catch (e) {
832
- sendJSON(res, 200, { response: `Error marking emails as read: ${e.message}` });
833
- }
834
- logRequest(method, pathname, 200, Date.now() - start);
835
- return;
836
- }
837
-
838
- // ── @agent inline routing ────────────────────────────────
839
- let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
840
- const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
841
- if (atMatch) {
842
- const inlineAgent = atMatch[1].toLowerCase();
843
- body.message = atMatch[2];
844
- const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
845
- if (fs.existsSync(agentFile)) {
846
- const src = fs.readFileSync(agentFile, 'utf-8');
847
- const parsed = parseAgentFile(src, inlineAgent);
848
- if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
849
- } else {
850
- effectiveSystemPrompt = `You are the ${inlineAgent} AI agent. Be expert, concise, and helpful.`;
851
- }
852
- }
853
-
854
648
  if (!config.llm.apiKey) {
855
649
  sendJSON(res, 200, { response: 'No API key configured. Run: nha config set key YOUR_KEY', error: 'no_api_key' });
856
650
  logRequest(method, pathname, 200, Date.now() - start);
857
651
  return;
858
652
  }
859
653
 
860
- // Build message with rolling context (same strategy as streaming path)
861
- const requestHistory = (body.history || []).map(h => ({
862
- role: h.role,
863
- content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
864
- }));
865
- const RECENT = 6;
654
+ // Build message with history (merge persisted + request history)
655
+ const requestHistory = body.history || [];
866
656
  const parts = [];
867
- if (requestHistory.length > RECENT) {
868
- const older = requestHistory.slice(0, -RECENT);
869
- const sLines = [];
870
- for (let i = 0; i < older.length; i += 2) {
871
- const u = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
872
- const a = older[i + 1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
873
- if (u) sLines.push(`- User: "${u.trim()}${u.length >= 150 ? '...' : ''}" → ${a.trim()}${a.length >= 200 ? '...' : ''}`);
874
- }
875
- if (sLines.length > 0) parts.push(`[CONTEXT — ${sLines.length} earlier exchanges]\n${sLines.join('\n')}\n[END CONTEXT]`);
876
- }
877
- for (const turn of requestHistory.slice(-RECENT)) {
878
- parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turn.content.slice(0, 2000)}`);
657
+ for (const turn of requestHistory) {
658
+ const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
659
+ parts.push(`${prefix} ${turn.content}`);
879
660
  }
880
661
  parts.push(`[User] ${body.message}`);
881
- let userMessage = parts.join('\n\n');
662
+ const userMessage = parts.join('\n\n');
882
663
 
883
664
  // Inject episodic memory context into the system prompt
884
- const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
885
- let enrichedSystemPrompt = basePrompt;
665
+ let enrichedSystemPrompt = chatSystemPrompt;
886
666
  try {
887
667
  const memCtx = buildMemoryContext('chat', body.message);
888
- if (memCtx) enrichedSystemPrompt = basePrompt + memCtx;
668
+ if (memCtx) enrichedSystemPrompt = chatSystemPrompt + memCtx;
889
669
  } catch { /* memory unavailable */ }
890
670
 
891
- // Handle image attachment — vision API
892
- if (body.imageBase64 && body.imageMimeType) {
893
- try {
894
- const provider = config.llm.provider || 'anthropic';
895
- const apiKey = config.llm.apiKey;
896
- const model = config.llm.model;
897
- const imagePrompt = body.message || 'Describe this image in detail. Extract any text or important information.';
898
- let visionResponse = '';
899
-
900
- if (provider === 'anthropic') {
901
- const r = await fetch('https://api.anthropic.com/v1/messages', {
902
- method: 'POST',
903
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
904
- body: JSON.stringify({
905
- model: model || 'claude-sonnet-4-20250514', max_tokens: 4096, system: enrichedSystemPrompt,
906
- messages: [{ role: 'user', content: [
907
- { type: 'image', source: { type: 'base64', media_type: body.imageMimeType, data: body.imageBase64 } },
908
- { type: 'text', text: imagePrompt },
909
- ]}],
910
- }),
911
- });
912
- if (!r.ok) throw new Error(`Anthropic ${r.status}`);
913
- const d = await r.json();
914
- visionResponse = d.content?.[0]?.text || '';
915
- } else if (provider === 'openai') {
916
- const r = await fetch('https://api.openai.com/v1/chat/completions', {
917
- method: 'POST',
918
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
919
- body: JSON.stringify({
920
- model: model || 'gpt-4o-mini', max_tokens: 4096,
921
- messages: [
922
- { role: 'system', content: enrichedSystemPrompt },
923
- { role: 'user', content: [
924
- { type: 'image_url', image_url: { url: `data:${body.imageMimeType};base64,${body.imageBase64}` } },
925
- { type: 'text', text: imagePrompt },
926
- ]},
927
- ],
928
- }),
929
- });
930
- if (!r.ok) throw new Error(`OpenAI ${r.status}`);
931
- const d = await r.json();
932
- visionResponse = d.choices?.[0]?.message?.content || '';
933
- } else if (provider === 'gemini') {
934
- const m = model || 'gemini-2.0-flash';
935
- const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`, {
936
- method: 'POST',
937
- headers: { 'Content-Type': 'application/json' },
938
- body: JSON.stringify({
939
- system_instruction: { parts: [{ text: enrichedSystemPrompt }] },
940
- contents: [{ parts: [
941
- { inline_data: { mime_type: body.imageMimeType, data: body.imageBase64 } },
942
- { text: imagePrompt },
943
- ]}],
944
- generationConfig: { maxOutputTokens: 4096 },
945
- }),
946
- });
947
- if (!r.ok) throw new Error(`Gemini ${r.status}`);
948
- const d = await r.json();
949
- visionResponse = d.candidates?.[0]?.content?.parts?.[0]?.text || '';
950
- } else {
951
- visionResponse = `Vision not supported for provider "${provider}". Use anthropic, openai, or gemini.`;
952
- }
953
-
954
- sendJSON(res, 200, { response: visionResponse });
955
- logRequest(method, pathname, 200, Date.now() - start);
956
- return;
957
- } catch (e) {
958
- sendJSON(res, 200, { response: null, error: e.message });
959
- logRequest(method, pathname, 200, Date.now() - start);
960
- return;
961
- }
962
- }
963
-
964
- // Handle PDF attachment — send as document to Claude (native PDF support)
965
- if (body.pdfBase64 && body.pdfName) {
966
- try {
967
- const provider = config.llm.provider || 'anthropic';
968
- const apiKey = config.llm.apiKey;
969
- const model = config.llm.model;
970
- const pdfPrompt = body.message || `Read and analyze this PDF document "${body.pdfName}". Extract all text content, summarize key information.`;
971
- let pdfResponse = '';
972
-
973
- if (provider === 'anthropic') {
974
- const r = await fetch('https://api.anthropic.com/v1/messages', {
975
- method: 'POST',
976
- headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
977
- body: JSON.stringify({
978
- model: model || 'claude-sonnet-4-20250514', max_tokens: 8192, system: enrichedSystemPrompt,
979
- messages: [{ role: 'user', content: [
980
- { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: body.pdfBase64 } },
981
- { type: 'text', text: pdfPrompt },
982
- ]}],
983
- }),
984
- });
985
- if (!r.ok) throw new Error(`Anthropic ${r.status}: ${(await r.text()).slice(0, 200)}`);
986
- const d = await r.json();
987
- pdfResponse = d.content?.[0]?.text || '';
988
- } else if (provider === 'gemini') {
989
- const m = model || 'gemini-2.0-flash';
990
- const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`, {
991
- method: 'POST',
992
- headers: { 'Content-Type': 'application/json' },
993
- body: JSON.stringify({
994
- system_instruction: { parts: [{ text: enrichedSystemPrompt }] },
995
- contents: [{ parts: [
996
- { inline_data: { mime_type: 'application/pdf', data: body.pdfBase64 } },
997
- { text: pdfPrompt },
998
- ]}],
999
- generationConfig: { maxOutputTokens: 8192 },
1000
- }),
1001
- });
1002
- if (!r.ok) throw new Error(`Gemini ${r.status}`);
1003
- const d = await r.json();
1004
- pdfResponse = d.candidates?.[0]?.content?.parts?.[0]?.text || '';
1005
- } else {
1006
- pdfResponse = `PDF reading requires Anthropic (Claude) or Gemini. Your provider "${provider}" does not support native PDF documents.`;
1007
- }
1008
-
1009
- sendJSON(res, 200, { response: pdfResponse });
1010
- logRequest(method, pathname, 200, Date.now() - start);
1011
- return;
1012
- } catch (e) {
1013
- sendJSON(res, 200, { response: null, error: `PDF error: ${e.message}` });
1014
- logRequest(method, pathname, 200, Date.now() - start);
1015
- return;
1016
- }
1017
- }
1018
-
1019
- // Handle text file attachment
1020
- if (body.fileContent && body.fileName) {
1021
- const filePrompt = body.message
1022
- ? `User asks about file "${body.fileName}": ${body.message}\n\nFile content:\n${body.fileContent.slice(0, 8000)}`
1023
- : `Analyze this file "${body.fileName}":\n\n${body.fileContent.slice(0, 8000)}`;
1024
- userMessage = filePrompt;
1025
- }
1026
-
1027
671
  try {
1028
672
  const response = await callLLM(config, enrichedSystemPrompt, userMessage);
1029
673
  const { textParts, actions } = parseActions(response);
@@ -1043,11 +687,8 @@ export async function cmdUI(args) {
1043
687
  let fullResponse;
1044
688
  if (toolResults.length > 0) {
1045
689
  // Second LLM call with real tool results — forces the LLM to use actual data
1046
- const toolContext = toolResults.map(t => {
1047
- let clean = t.result.replace(/\[Screenshot[^\]]*\]/g, '').replace(/!\[.*?\]\(data:image[^)]+\)/g, '').slice(0, 3000);
1048
- return `[${t.action} result]: ${clean.trim()}`;
1049
- }).join('\n\n');
1050
- 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.`;
690
+ const toolContext = toolResults.map(t => `[${t.action} result]: ${t.result}`).join('\n\n');
691
+ 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.`;
1051
692
  try {
1052
693
  fullResponse = await callLLM(config, enrichedSystemPrompt, followUp);
1053
694
  } catch {
@@ -1075,334 +716,6 @@ export async function cmdUI(args) {
1075
716
  return;
1076
717
  }
1077
718
 
1078
- // ── Conversations API ────────────────────────────────────────────
1079
-
1080
- // GET /api/conversations — list all
1081
- if (method === 'GET' && pathname === '/api/conversations') {
1082
- const convs = listConversations();
1083
- sendJSON(res, 200, { conversations: convs });
1084
- logRequest(method, pathname, 200, Date.now() - start);
1085
- return;
1086
- }
1087
-
1088
- // POST /api/conversations — create new
1089
- if (method === 'POST' && pathname === '/api/conversations') {
1090
- const conv = createConversation();
1091
- setActiveId(conv.id);
1092
- sendJSON(res, 201, { conversation: conv });
1093
- logRequest(method, pathname, 201, Date.now() - start);
1094
- return;
1095
- }
1096
-
1097
- // GET /api/conversations/:id
1098
- if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1099
- const id = pathname.split('/')[3];
1100
- const conv = loadConversation(id);
1101
- if (!conv) { sendJSON(res, 404, { error: 'Conversation not found' }); }
1102
- else { sendJSON(res, 200, { conversation: conv }); }
1103
- logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1104
- return;
1105
- }
1106
-
1107
- // DELETE /api/conversations/:id
1108
- if (method === 'DELETE' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1109
- const id = pathname.split('/')[3];
1110
- const ok = deleteConversation(id);
1111
- sendJSON(res, ok ? 200 : 404, { ok });
1112
- logRequest(method, pathname, ok ? 200 : 404, Date.now() - start);
1113
- return;
1114
- }
1115
-
1116
- // PATCH /api/conversations/:id — rename
1117
- if (method === 'PATCH' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1118
- const id = pathname.split('/')[3];
1119
- const body = await parseBody(req);
1120
- const conv = loadConversation(id);
1121
- if (!conv) { sendJSON(res, 404, { error: 'Not found' }); }
1122
- else {
1123
- if (body.title) conv.title = body.title;
1124
- saveConversation(conv);
1125
- sendJSON(res, 200, { conversation: conv });
1126
- }
1127
- logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1128
- return;
1129
- }
1130
-
1131
- // GET /api/conversations/:id/export?format=md|json
1132
- if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/export$/)) {
1133
- const id = pathname.split('/')[3];
1134
- const conv = loadConversation(id);
1135
- if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1136
- const url = new URL(req.url, `http://${req.headers.host}`);
1137
- const format = url.searchParams.get('format') || 'md';
1138
- if (format === 'json') {
1139
- const exported = exportAsJson(conv);
1140
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="nha-chat-${id}.json"` });
1141
- res.end(exported);
1142
- } else {
1143
- const exported = exportAsMarkdown(conv);
1144
- res.writeHead(200, { 'Content-Type': 'text/markdown', 'Content-Disposition': `attachment; filename="nha-chat-${id}.md"` });
1145
- res.end(exported);
1146
- }
1147
- logRequest(method, pathname, 200, Date.now() - start);
1148
- return;
1149
- }
1150
-
1151
- // ── Streaming Chat API ─────────────────────────────────────────────
1152
-
1153
- // POST /api/chat/stream — SSE streaming chat with conversation persistence
1154
- if (method === 'POST' && pathname === '/api/chat/stream') {
1155
- const body = await parseBody(req);
1156
- if (!body.message) { sendJSON(res, 400, { error: 'message required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1157
- if (!config.llm.apiKey) { sendJSON(res, 200, { error: 'no_api_key' }); logRequest(method, pathname, 200, Date.now() - start); return; }
1158
-
1159
- const msg = body.message.trim();
1160
- const convId = body.conversationId;
1161
-
1162
- // Build system prompt
1163
- let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
1164
- let effectiveMsg = msg;
1165
- const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
1166
- if (atMatch) {
1167
- const inlineAgent = atMatch[1].toLowerCase();
1168
- effectiveMsg = atMatch[2];
1169
- const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
1170
- if (fs.existsSync(agentFile)) {
1171
- const src = fs.readFileSync(agentFile, 'utf-8');
1172
- const parsed = parseAgentFile(src, inlineAgent);
1173
- if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
1174
- }
1175
- }
1176
-
1177
- const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
1178
- let enrichedPrompt = basePrompt;
1179
- try { const m = buildMemoryContext('chat', effectiveMsg); if (m) enrichedPrompt = basePrompt + m; } catch {}
1180
-
1181
- // Build message with rolling context window:
1182
- // - Recent messages (last 6): full content up to 2000 chars
1183
- // - Older messages: compressed to 1-line summaries preserving full context
1184
- const rawHistory = (body.history || []).map(h => ({
1185
- role: h.role,
1186
- content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
1187
- }));
1188
-
1189
- const RECENT_COUNT = 6;
1190
- const parts = [];
1191
-
1192
- if (rawHistory.length > RECENT_COUNT) {
1193
- // Compress older messages into a conversation summary
1194
- const older = rawHistory.slice(0, -RECENT_COUNT);
1195
- const summaryLines = [];
1196
- for (let i = 0; i < older.length; i += 2) {
1197
- const userMsg = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
1198
- const assistantMsg = older[i + 1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
1199
- if (userMsg) summaryLines.push(`- User asked: "${userMsg.trim()}${userMsg.length >= 150 ? '...' : ''}" → Assistant: ${assistantMsg.trim()}${assistantMsg.length >= 200 ? '...' : ''}`);
1200
- }
1201
- if (summaryLines.length > 0) {
1202
- parts.push(`[CONVERSATION CONTEXT — ${summaryLines.length} earlier exchanges]\n${summaryLines.join('\n')}\n[END CONTEXT]`);
1203
- }
1204
- }
1205
-
1206
- // Recent messages in full
1207
- const recent = rawHistory.slice(-RECENT_COUNT);
1208
- for (const turn of recent) {
1209
- const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
1210
- parts.push(`${prefix} ${turn.content.slice(0, 2000)}`);
1211
- }
1212
-
1213
- parts.push(`[User] ${effectiveMsg}`);
1214
- const userMessage = parts.join('\n\n');
1215
-
1216
- // Handle file/image/pdf attachments — fall back to non-streaming
1217
- if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
1218
- // Redirect to regular /api/chat for attachment handling
1219
- sendJSON(res, 200, { error: 'attachments_use_regular', redirect: '/api/chat' });
1220
- logRequest(method, pathname, 200, Date.now() - start);
1221
- return;
1222
- }
1223
-
1224
- // SSE headers
1225
- res.writeHead(200, {
1226
- 'Content-Type': 'text/event-stream',
1227
- 'Cache-Control': 'no-cache',
1228
- 'Connection': 'keep-alive',
1229
- 'Access-Control-Allow-Origin': '*',
1230
- });
1231
-
1232
- const sendSSE = (event, data) => {
1233
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1234
- };
1235
-
1236
- sendSSE('processing', {});
1237
-
1238
- try {
1239
- let fullResponse = '';
1240
- fullResponse = await callLLMStream(config, enrichedPrompt, userMessage, (chunk) => {
1241
- sendSSE('token', { content: chunk });
1242
- });
1243
-
1244
- // Parse and execute tools
1245
- const { textParts, actions } = parseActions(fullResponse);
1246
- const toolResults = [];
1247
-
1248
- // Auto-detect search + screenshot intent from user message
1249
- const wantsScreenshot = /screenshot|screen\s*shot|schermo|cattura|foto|immagine/i.test(msg);
1250
- const wantsSearch = /\b(cerca|search|find|look\s*up|ricerca|cercare)\b/i.test(msg);
1251
-
1252
- // If user asked to search but LLM didn't call web_search, force it
1253
- if (wantsSearch && !actions.some(a => a.action === 'web_search')) {
1254
- // Extract search query from message (remove action words)
1255
- 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();
1256
- if (searchQuery.length > 2) {
1257
- actions.push({ action: 'web_search', params: { query: searchQuery, screenshot: wantsScreenshot } });
1258
- }
1259
- }
1260
-
1261
- for (const { action, params } of actions) {
1262
- // Force screenshot=true on web_search if user asked for screenshot
1263
- if (action === 'web_search' && wantsScreenshot && !params.screenshot) {
1264
- params.screenshot = true;
1265
- }
1266
-
1267
- sendSSE('tool', { action, status: 'executing' });
1268
- try {
1269
- // For browser_screenshot in web UI: capture and send base64 image
1270
- if (action === 'browser_screenshot') {
1271
- const be = await import('../services/browser-engine.mjs');
1272
- if (!be.isBrowserRunning()) {
1273
- toolResults.push({ action, result: 'No browser open. Use browser_open first.' });
1274
- sendSSE('tool', { action, status: 'error', error: 'No browser open' });
1275
- continue;
1276
- }
1277
- // Scroll to top for best viewport
1278
- await be.browserScroll({ direction: 'top' });
1279
- await new Promise(r => setTimeout(r, 300));
1280
- const ssResult = await be.browserScreenshot({
1281
- fullPage: false, // Always viewport
1282
- format: 'jpeg',
1283
- quality: 75,
1284
- });
1285
- if (!ssResult.error) {
1286
- // Save screenshot to disk for persistence across sessions
1287
- const ssDir = path.join(NHA_DIR, 'screenshots');
1288
- fs.mkdirSync(ssDir, { recursive: true });
1289
- const ssFilename = `ss-${Date.now()}.jpg`;
1290
- fs.writeFileSync(path.join(ssDir, ssFilename), Buffer.from(ssResult.base64, 'base64'));
1291
-
1292
- sendSSE('screenshot', { base64: ssResult.base64, format: 'jpeg', filename: ssFilename });
1293
- toolResults.push({ action, result: `Screenshot captured (${Math.round(ssResult.size / 1024)}KB) [file: ${ssFilename}]` });
1294
- // Store screenshot ref for persistence
1295
- if (!res._screenshotFiles) res._screenshotFiles = [];
1296
- res._screenshotFiles.push(ssFilename);
1297
- sendSSE('tool', { action, status: 'done', result: 'Screenshot captured' });
1298
- } else {
1299
- toolResults.push({ action, result: `Error: ${ssResult.message}` });
1300
- sendSSE('tool', { action, status: 'error', error: ssResult.message });
1301
- }
1302
- continue;
1303
- }
1304
-
1305
- const result = await executeTool(action, params, config);
1306
- const resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
1307
- toolResults.push({ action, result: resultStr });
1308
- sendSSE('tool', { action, status: 'done', result: typeof resultStr === 'string' ? resultStr.slice(0, 500) : '' });
1309
-
1310
- // Send live browser frame after browser actions (low-quality thumbnail for viewer)
1311
- if (action.startsWith('browser_') && action !== 'browser_close') {
1312
- try {
1313
- const be = await import('../services/browser-engine.mjs');
1314
- if (be.isBrowserRunning()) {
1315
- const frame = await be.browserScreenshot({ fullPage: false, format: 'jpeg', quality: 30 });
1316
- if (!frame.error) {
1317
- const info = await be.browserInfo();
1318
- sendSSE('browser_frame', { base64: frame.base64, format: 'jpeg', url: (info.url || '').slice(0, 80) });
1319
- }
1320
- }
1321
- } catch { /* frame capture failed, non-critical */ }
1322
- }
1323
-
1324
- // If the tool produced a screenshot (web_search with screenshot=true), send it via SSE
1325
- if (resultStr.includes('[Screenshot of results captured')) {
1326
- try {
1327
- const fileMatch = resultStr.match(/file:(ss-\d+\.jpg)/);
1328
- console.log(` [screenshot] file match: ${fileMatch?.[1] || 'NONE'}`);
1329
- if (fileMatch) {
1330
- const ssFilename = fileMatch[1];
1331
- const ssPath = path.join(NHA_DIR, 'screenshots', ssFilename);
1332
- const exists = fs.existsSync(ssPath);
1333
- console.log(` [screenshot] path: ${ssPath}, exists: ${exists}`);
1334
- if (exists) {
1335
- const ssBase64 = fs.readFileSync(ssPath).toString('base64');
1336
- console.log(` [screenshot] sending SSE, base64 size: ${ssBase64.length}`);
1337
- sendSSE('screenshot', { base64: ssBase64, format: 'jpeg', filename: ssFilename });
1338
- sendSSE('browser_frame', { base64: ssBase64, format: 'jpeg', url: 'Search results' });
1339
- if (!res._screenshotFiles) res._screenshotFiles = [];
1340
- res._screenshotFiles.push(ssFilename);
1341
- }
1342
- }
1343
- } catch (ssErr) { console.log(` [screenshot] ERROR: ${ssErr.message}`); }
1344
- }
1345
- } catch (e) {
1346
- toolResults.push({ action, result: `Error: ${e.message}` });
1347
- sendSSE('tool', { action, status: 'error', error: e.message });
1348
- }
1349
- }
1350
-
1351
- // If tools were executed, make a second LLM call with results
1352
- let finalResponse = fullResponse;
1353
- if (toolResults.length > 0) {
1354
- const toolContext = toolResults.map(t => {
1355
- // Strip screenshot file references and base64 from tool results — the screenshot was already sent to the UI
1356
- let clean = t.result.replace(/\[Screenshot[^\]]*\]/g, '').replace(/!\[.*?\]\(data:image[^)]+\)/g, '').slice(0, 3000);
1357
- return `[${t.action} result]: ${clean.trim()}`;
1358
- }).join('\n\n');
1359
- 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.`;
1360
- sendSSE('tool_synthesis', {});
1361
- try {
1362
- finalResponse = await callLLMStream(config, enrichedPrompt, followUp, (chunk) => {
1363
- sendSSE('token', { content: chunk });
1364
- });
1365
- // Strip any JSON blocks and base64 the LLM might have emitted
1366
- finalResponse = finalResponse
1367
- .replace(/```json[\s\S]*?```/g, '')
1368
- .replace(/!\[.*?\]\(data:image\/[^)]+\)/g, '')
1369
- .replace(/data:image\/[a-z]+;base64,[A-Za-z0-9+/=]{100,}/g, '[image]')
1370
- .trim();
1371
- } catch {
1372
- finalResponse = toolResults.map(t => `${t.action}: ${t.result}`).join('\n\n');
1373
- }
1374
- }
1375
-
1376
- // Persist to conversation (append screenshot references so they survive reload)
1377
- if (convId) {
1378
- try {
1379
- let persistedResponse = finalResponse;
1380
- const ssFiles = res._screenshotFiles || [];
1381
- if (ssFiles.length > 0) {
1382
- const ssRefs = ssFiles.map(f => `\n![Screenshot](/api/screenshots/${f})`).join('');
1383
- persistedResponse = finalResponse + ssRefs;
1384
- }
1385
- const conv = loadConversation(convId);
1386
- if (conv) {
1387
- addMessages(conv, msg, persistedResponse);
1388
- }
1389
- } catch {}
1390
- }
1391
-
1392
- // Extract memory
1393
- try { extractMemory('chat', msg, finalResponse); } catch {}
1394
-
1395
- const ssFiles = res._screenshotFiles || [];
1396
- sendSSE('done', { content: finalResponse, screenshotFiles: ssFiles });
1397
- } catch (e) {
1398
- sendSSE('error', { message: e.message });
1399
- }
1400
-
1401
- res.end();
1402
- logRequest(method, pathname, 200, Date.now() - start);
1403
- return;
1404
- }
1405
-
1406
719
  // GET /api/agents
1407
720
  if (method === 'GET' && pathname === '/api/agents') {
1408
721
  sendJSON(res, 200, { agents: agentCards });
@@ -1410,91 +723,6 @@ export async function cmdUI(args) {
1410
723
  return;
1411
724
  }
1412
725
 
1413
- // POST /api/agents — create custom agent
1414
- if (method === 'POST' && pathname === '/api/agents') {
1415
- const body = await parseBody(req);
1416
- const name = (body.name || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
1417
- const tagline = body.tagline || '';
1418
- const systemPrompt = body.systemPrompt || '';
1419
- if (!name || !tagline || !systemPrompt) {
1420
- sendJSON(res, 400, { error: 'name, tagline, and systemPrompt required' });
1421
- logRequest(method, pathname, 400, Date.now() - start);
1422
- return;
1423
- }
1424
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1425
- 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`;
1426
- if (!fs.existsSync(AGENTS_DIR)) fs.mkdirSync(AGENTS_DIR, { recursive: true });
1427
- fs.writeFileSync(agentFile, content, 'utf-8');
1428
- const existingIdx = agentCards.findIndex(a => a.name === name);
1429
- if (existingIdx >= 0) {
1430
- agentCards[existingIdx] = { name, displayName: name.toUpperCase(), category: 'custom', tagline };
1431
- } else {
1432
- agentCards.push({ name, displayName: name.toUpperCase(), category: 'custom', tagline });
1433
- }
1434
- sendJSON(res, 201, { ok: true, agent: { name, category: 'custom', tagline } });
1435
- logRequest(method, pathname, 201, Date.now() - start);
1436
- return;
1437
- }
1438
-
1439
- // PUT /api/agents/:name — edit agent
1440
- if (method === 'PUT' && pathname.startsWith('/api/agents/')) {
1441
- const name = pathname.split('/')[3];
1442
- const body = await parseBody(req);
1443
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1444
- if (!fs.existsSync(agentFile)) {
1445
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1446
- logRequest(method, pathname, 404, Date.now() - start);
1447
- return;
1448
- }
1449
- const tagline = body.tagline || '';
1450
- const systemPrompt = body.systemPrompt || '';
1451
- if (!tagline || !systemPrompt) {
1452
- sendJSON(res, 400, { error: 'tagline and systemPrompt required' });
1453
- logRequest(method, pathname, 400, Date.now() - start);
1454
- return;
1455
- }
1456
- 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`;
1457
- fs.writeFileSync(agentFile, content, 'utf-8');
1458
- const idx = agentCards.findIndex(a => a.name === name);
1459
- if (idx >= 0) { agentCards[idx].tagline = tagline; }
1460
- sendJSON(res, 200, { ok: true });
1461
- logRequest(method, pathname, 200, Date.now() - start);
1462
- return;
1463
- }
1464
-
1465
- // DELETE /api/agents/:name — delete agent
1466
- if (method === 'DELETE' && pathname.startsWith('/api/agents/')) {
1467
- const name = pathname.split('/')[3];
1468
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1469
- if (!fs.existsSync(agentFile)) {
1470
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1471
- logRequest(method, pathname, 404, Date.now() - start);
1472
- return;
1473
- }
1474
- fs.unlinkSync(agentFile);
1475
- const idx = agentCards.findIndex(a => a.name === name);
1476
- if (idx >= 0) agentCards.splice(idx, 1);
1477
- sendJSON(res, 200, { ok: true });
1478
- logRequest(method, pathname, 200, Date.now() - start);
1479
- return;
1480
- }
1481
-
1482
- // GET /api/agents/:name — get agent details (system prompt)
1483
- if (method === 'GET' && pathname.startsWith('/api/agents/') && pathname.split('/').length === 4) {
1484
- const name = pathname.split('/')[3];
1485
- const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
1486
- if (!fs.existsSync(agentFile)) {
1487
- sendJSON(res, 404, { error: `Agent "${name}" not found` });
1488
- logRequest(method, pathname, 404, Date.now() - start);
1489
- return;
1490
- }
1491
- const src = fs.readFileSync(agentFile, 'utf-8');
1492
- const parsed = parseAgentFile(src, name);
1493
- sendJSON(res, 200, { name, category: parsed.card?.category || 'custom', tagline: parsed.card?.tagline || '', systemPrompt: parsed.systemPrompt || '' });
1494
- logRequest(method, pathname, 200, Date.now() - start);
1495
- return;
1496
- }
1497
-
1498
726
  // POST /api/ask — agent call with personal context (email, calendar, tasks)
1499
727
  if (method === 'POST' && pathname === '/api/ask') {
1500
728
  const body = await parseBody(req);
@@ -1510,9 +738,7 @@ export async function cmdUI(args) {
1510
738
  return;
1511
739
  }
1512
740
 
1513
- // Allow both built-in and custom agents
1514
- const agentFile = path.join(AGENTS_DIR, `${body.agent}.mjs`);
1515
- if (!AGENTS.includes(body.agent) && !fs.existsSync(agentFile)) {
741
+ if (!AGENTS.includes(body.agent)) {
1516
742
  sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
1517
743
  logRequest(method, pathname, 400, Date.now() - start);
1518
744
  return;