nothumanallowed 9.7.2 → 9.8.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.
- package/package.json +1 -1
- package/src/commands/ask.mjs +206 -18
- package/src/commands/chat.mjs +482 -64
- package/src/commands/ui.mjs +843 -89
- package/src/constants.mjs +1 -1
- package/src/services/browser-engine.mjs +1240 -0
- package/src/services/conversations.mjs +277 -0
- package/src/services/tool-executor.mjs +384 -59
- package/src/services/web-tools.mjs +430 -0
- package/src/services/web-ui.mjs +422 -175
package/src/commands/ui.mjs
CHANGED
|
@@ -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,
|
|
18
|
+
import { callLLM, callLLMStream, 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,6 +28,20 @@ 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';
|
|
31
45
|
import { info, ok, fail, warn, C, G, D, NC, BOLD } from '../ui.mjs';
|
|
32
46
|
import {
|
|
33
47
|
parseActions,
|
|
@@ -78,7 +92,7 @@ function sendJSON(res, statusCode, data) {
|
|
|
78
92
|
res.writeHead(statusCode, {
|
|
79
93
|
'Content-Type': 'application/json',
|
|
80
94
|
'Access-Control-Allow-Origin': '*',
|
|
81
|
-
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
|
|
95
|
+
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
82
96
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
83
97
|
'Cache-Control': 'no-cache',
|
|
84
98
|
});
|
|
@@ -151,6 +165,9 @@ export async function cmdUI(args) {
|
|
|
151
165
|
const config = loadConfig();
|
|
152
166
|
const htmlPage = getHTML(port);
|
|
153
167
|
|
|
168
|
+
// Migrate old chat history to multi-conversation format
|
|
169
|
+
migrateOldHistory();
|
|
170
|
+
|
|
154
171
|
// Pre-load agent cards once at startup
|
|
155
172
|
const agentCards = loadAgentCards();
|
|
156
173
|
|
|
@@ -158,12 +175,7 @@ export async function cmdUI(args) {
|
|
|
158
175
|
const UI_PERSONA = `You are NHA Chat, a personal operations assistant inside the NotHumanAllowed web UI. ` +
|
|
159
176
|
`You help the user manage their emails, calendar, tasks, GitHub issues, Notion pages, and Slack channels through natural conversation. ` +
|
|
160
177
|
`Be concise, helpful, and proactive. When presenting data, format it clearly. ` +
|
|
161
|
-
`Never output raw JSON to the user
|
|
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.`;
|
|
178
|
+
`Never output raw JSON to the user.`;
|
|
167
179
|
const chatSystemPrompt = buildSystemPrompt('NHA UI', UI_PERSONA, config);
|
|
168
180
|
|
|
169
181
|
// ── Route Handlers ──────────────────────────────────────────────────────
|
|
@@ -178,7 +190,7 @@ export async function cmdUI(args) {
|
|
|
178
190
|
if (method === 'OPTIONS') {
|
|
179
191
|
res.writeHead(204, {
|
|
180
192
|
'Access-Control-Allow-Origin': '*',
|
|
181
|
-
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
|
|
193
|
+
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
182
194
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
183
195
|
});
|
|
184
196
|
res.end();
|
|
@@ -221,22 +233,46 @@ export async function cmdUI(args) {
|
|
|
221
233
|
|
|
222
234
|
// ── API Routes ────────────────────────────────────────────────────
|
|
223
235
|
|
|
224
|
-
// GET /api/screenshots/:filename — serve
|
|
236
|
+
// GET /api/screenshots/:filename — serve saved screenshots from disk
|
|
225
237
|
if (method === 'GET' && pathname.startsWith('/api/screenshots/')) {
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
if (fs.existsSync(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
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 });
|
|
239
267
|
}
|
|
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);
|
|
240
276
|
return;
|
|
241
277
|
}
|
|
242
278
|
|
|
@@ -314,6 +350,40 @@ export async function cmdUI(args) {
|
|
|
314
350
|
return;
|
|
315
351
|
}
|
|
316
352
|
|
|
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
|
+
|
|
317
387
|
// POST /api/contacts — create contact
|
|
318
388
|
if (method === 'POST' && pathname === '/api/contacts') {
|
|
319
389
|
try {
|
|
@@ -516,28 +586,50 @@ export async function cmdUI(args) {
|
|
|
516
586
|
return;
|
|
517
587
|
}
|
|
518
588
|
|
|
519
|
-
// GET /api/emails?filter=unread|all
|
|
589
|
+
// GET /api/emails?page=0&pageSize=25&filter=unread|all
|
|
520
590
|
if (method === 'GET' && pathname === '/api/emails') {
|
|
521
591
|
try {
|
|
522
592
|
const filter = url.searchParams.get('filter');
|
|
523
|
-
|
|
593
|
+
const page = parseInt(url.searchParams.get('page') || '0', 10);
|
|
594
|
+
const pageSize = parseInt(url.searchParams.get('pageSize') || '25', 10);
|
|
595
|
+
|
|
524
596
|
if (filter === 'unread') {
|
|
525
|
-
emails = await getUnreadImportant(config,
|
|
597
|
+
const emails = await getUnreadImportant(config, pageSize);
|
|
598
|
+
sendJSON(res, 200, { emails, page, hasMore: false });
|
|
526
599
|
} else {
|
|
527
|
-
// Show all recent inbox emails (read + unread)
|
|
528
600
|
const gm = await import('../services/google-gmail.mjs');
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
+
}
|
|
536
627
|
}
|
|
628
|
+
|
|
629
|
+
sendJSON(res, 200, { emails, page, hasMore, totalCached: config._emailCache?.length || 0 });
|
|
537
630
|
}
|
|
538
|
-
sendJSON(res, 200, { emails });
|
|
539
631
|
} catch (e) {
|
|
540
|
-
sendJSON(res, 200, { emails: [], error: e.message });
|
|
632
|
+
sendJSON(res, 200, { emails: [], error: e.message, page: 0, hasMore: false });
|
|
541
633
|
}
|
|
542
634
|
logRequest(method, pathname, 200, Date.now() - start);
|
|
543
635
|
return;
|
|
@@ -650,29 +742,310 @@ export async function cmdUI(args) {
|
|
|
650
742
|
return;
|
|
651
743
|
}
|
|
652
744
|
|
|
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
|
+
|
|
653
876
|
if (!config.llm.apiKey) {
|
|
654
877
|
sendJSON(res, 200, { response: 'No API key configured. Run: nha config set key YOUR_KEY', error: 'no_api_key' });
|
|
655
878
|
logRequest(method, pathname, 200, Date.now() - start);
|
|
656
879
|
return;
|
|
657
880
|
}
|
|
658
881
|
|
|
659
|
-
// Build message with
|
|
660
|
-
const requestHistory = body.history || []
|
|
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;
|
|
661
888
|
const parts = [];
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
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)}`);
|
|
665
901
|
}
|
|
666
902
|
parts.push(`[User] ${body.message}`);
|
|
667
|
-
|
|
903
|
+
let userMessage = parts.join('\n\n');
|
|
668
904
|
|
|
669
905
|
// Inject episodic memory context into the system prompt
|
|
670
|
-
|
|
906
|
+
const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
|
|
907
|
+
let enrichedSystemPrompt = basePrompt;
|
|
671
908
|
try {
|
|
672
909
|
const memCtx = buildMemoryContext('chat', body.message);
|
|
673
|
-
if (memCtx) enrichedSystemPrompt =
|
|
910
|
+
if (memCtx) enrichedSystemPrompt = basePrompt + memCtx;
|
|
674
911
|
} catch { /* memory unavailable */ }
|
|
675
912
|
|
|
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
|
+
|
|
676
1049
|
try {
|
|
677
1050
|
const response = await callLLM(config, enrichedSystemPrompt, userMessage);
|
|
678
1051
|
const { textParts, actions } = parseActions(response);
|
|
@@ -680,49 +1053,27 @@ export async function cmdUI(args) {
|
|
|
680
1053
|
|
|
681
1054
|
// Execute ALL tool actions and collect results
|
|
682
1055
|
const toolResults = [];
|
|
683
|
-
let screenshotData = null; // For vision: { base64, path, question }
|
|
684
|
-
let screenshotFiles = []; // For displaying inline
|
|
685
1056
|
for (const { action, params } of actions) {
|
|
686
1057
|
try {
|
|
687
1058
|
const result = await executeTool(action, params, config);
|
|
688
|
-
|
|
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
|
-
}
|
|
1059
|
+
toolResults.push({ action, result: typeof result === 'object' ? JSON.stringify(result) : String(result) });
|
|
696
1060
|
} catch (e) {
|
|
697
1061
|
toolResults.push({ action, result: `Error: ${e.message}` });
|
|
698
1062
|
}
|
|
699
1063
|
}
|
|
700
1064
|
|
|
701
1065
|
let fullResponse;
|
|
702
|
-
if (
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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.`;
|
|
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.`;
|
|
723
1073
|
try {
|
|
724
1074
|
fullResponse = await callLLM(config, enrichedSystemPrompt, followUp);
|
|
725
1075
|
} catch {
|
|
1076
|
+
// Fallback: show raw results
|
|
726
1077
|
fullResponse = toolResults.map(t => `${t.action}: ${t.result}`).join('\n\n');
|
|
727
1078
|
}
|
|
728
1079
|
} else {
|
|
@@ -738,7 +1089,7 @@ export async function cmdUI(args) {
|
|
|
738
1089
|
} catch { /* non-critical */ }
|
|
739
1090
|
try { extractMemory('chat', body.message, fullResponse); } catch { /* non-critical */ }
|
|
740
1091
|
|
|
741
|
-
sendJSON(res, 200, { response: fullResponse, toolResults, actions
|
|
1092
|
+
sendJSON(res, 200, { response: fullResponse, toolResults, actions });
|
|
742
1093
|
} catch (e) {
|
|
743
1094
|
sendJSON(res, 200, { response: null, error: e.message });
|
|
744
1095
|
}
|
|
@@ -746,6 +1097,334 @@ export async function cmdUI(args) {
|
|
|
746
1097
|
return;
|
|
747
1098
|
}
|
|
748
1099
|
|
|
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`).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
|
+
|
|
749
1428
|
// GET /api/agents
|
|
750
1429
|
if (method === 'GET' && pathname === '/api/agents') {
|
|
751
1430
|
sendJSON(res, 200, { agents: agentCards });
|
|
@@ -753,6 +1432,91 @@ export async function cmdUI(args) {
|
|
|
753
1432
|
return;
|
|
754
1433
|
}
|
|
755
1434
|
|
|
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
|
+
|
|
756
1520
|
// POST /api/ask — agent call with personal context (email, calendar, tasks)
|
|
757
1521
|
if (method === 'POST' && pathname === '/api/ask') {
|
|
758
1522
|
const body = await parseBody(req);
|
|
@@ -768,7 +1532,9 @@ export async function cmdUI(args) {
|
|
|
768
1532
|
return;
|
|
769
1533
|
}
|
|
770
1534
|
|
|
771
|
-
|
|
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)) {
|
|
772
1538
|
sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
|
|
773
1539
|
logRequest(method, pathname, 400, Date.now() - start);
|
|
774
1540
|
return;
|
|
@@ -1059,18 +1825,6 @@ export async function cmdUI(args) {
|
|
|
1059
1825
|
if (!noBrowser) {
|
|
1060
1826
|
openBrowser(localUrl);
|
|
1061
1827
|
}
|
|
1062
|
-
|
|
1063
|
-
// Auto-start daemon if not running (for live email/calendar/cron updates)
|
|
1064
|
-
import('../services/ops-daemon.mjs').then(({ isRunning, startDaemon }) => {
|
|
1065
|
-
if (!isRunning()) {
|
|
1066
|
-
const daemonResult = startDaemon();
|
|
1067
|
-
if (daemonResult.ok) {
|
|
1068
|
-
console.log(` ${G}Daemon started${NC} (PID ${daemonResult.pid}) — live updates active`);
|
|
1069
|
-
}
|
|
1070
|
-
} else {
|
|
1071
|
-
console.log(` ${G}Daemon running${NC} — live updates active`);
|
|
1072
|
-
}
|
|
1073
|
-
}).catch(() => { /* daemon start failed — non-critical */ });
|
|
1074
1828
|
});
|
|
1075
1829
|
|
|
1076
1830
|
// Graceful shutdown
|