nothumanallowed 13.5.200 → 14.0.1

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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Chat routes — conversations CRUD + streaming chat with tool execution
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { sendJSON, sendError, parseBody, sendSSE } from '../index.mjs';
9
+ import { loadConfig } from '../../config.mjs';
10
+ import { NHA_DIR, AGENTS_DIR } from '../../constants.mjs';
11
+ import {
12
+ createConversation, loadConversation, saveConversation, deleteConversation,
13
+ listConversations, setActiveId, getHistory, addMessages, retryMessage,
14
+ addRetryResponse, editMessage, navigateFork, getForkInfo,
15
+ exportAsMarkdown, exportAsJson, migrateOldHistory,
16
+ } from '../../services/conversations.mjs';
17
+ import { callLLMStream, callLLM, callLLMVision, parseAgentFile } from '../../services/llm.mjs';
18
+ import { buildMemoryContext } from '../../services/memory.mjs';
19
+ import { parseActions, executeTool, buildSystemPrompt } from '../../services/tool-executor.mjs';
20
+
21
+ // Migrate on import (once)
22
+ migrateOldHistory();
23
+
24
+ const UI_PERSONA = `You are NHA Chat, a personal operations assistant inside the NotHumanAllowed web UI. \
25
+ You help the user manage their emails, calendar, tasks, GitHub issues, Notion pages, and Slack channels through natural conversation. \
26
+ Be concise, helpful, and proactive. When presenting data, format it clearly. \
27
+ Never output raw JSON to the user.`;
28
+
29
+ let _chatSystemPrompt = null;
30
+ async function getChatSystemPrompt() {
31
+ if (!_chatSystemPrompt) {
32
+ const config = loadConfig();
33
+ _chatSystemPrompt = await buildSystemPrompt('NHA UI', UI_PERSONA, config);
34
+ }
35
+ return _chatSystemPrompt;
36
+ }
37
+
38
+ async function getImapAccountsContext() {
39
+ try {
40
+ const { listAccounts } = await import('../../services/email-db.mjs');
41
+ const accs = listAccounts();
42
+ if (!accs.length) return '';
43
+ let ctx = '\n\n--- IMAP EMAIL ACCOUNTS (custom, already configured) ---\n';
44
+ ctx += 'Use these accountIds directly in imap_* tools — do NOT call imap_accounts() first.\n';
45
+ for (const a of accs) {
46
+ ctx += `accountId: "${a.id}" | email: ${a.email_address} | name: "${a.display_name}" | status: ${a.sync_status}\n`;
47
+ }
48
+ return ctx;
49
+ } catch { return ''; }
50
+ }
51
+
52
+ const CONV_RE = /^\/api\/conversations\/([a-z0-9-]+)$/;
53
+ const CONV_ACTION_RE = /^\/api\/conversations\/([a-z0-9-]+)\/(export|retry|retry-response|navigate|forks|edit)$/;
54
+
55
+ export function register(router) {
56
+
57
+ // ── Conversations CRUD ──────────────────────────────────────────────────
58
+
59
+ router.get('/api/conversations', (_req, res) => {
60
+ sendJSON(res, 200, { conversations: listConversations() });
61
+ });
62
+
63
+ router.post('/api/conversations', (_req, res) => {
64
+ const conv = createConversation();
65
+ setActiveId(conv.id);
66
+ sendJSON(res, 201, { conversation: conv });
67
+ });
68
+
69
+ // Dynamic: GET/DELETE/PATCH /api/conversations/:id
70
+ router.get(CONV_RE, (req, res) => {
71
+ const id = req.url.match(CONV_RE)?.[1];
72
+ const conv = loadConversation(id);
73
+ if (!conv) return sendError(res, 404, 'Conversation not found');
74
+ sendJSON(res, 200, { conversation: conv });
75
+ });
76
+
77
+ router.delete(CONV_RE, (req, res) => {
78
+ const id = req.url.match(CONV_RE)?.[1];
79
+ const ok = deleteConversation(id);
80
+ sendJSON(res, ok ? 200 : 404, { ok });
81
+ });
82
+
83
+ router.patch(CONV_RE, async (req, res) => {
84
+ const id = req.url.match(CONV_RE)?.[1];
85
+ const body = await parseBody(req);
86
+ const conv = loadConversation(id);
87
+ if (!conv) return sendError(res, 404, 'Not found');
88
+ if (body.title) conv.title = body.title;
89
+ saveConversation(conv);
90
+ sendJSON(res, 200, { conversation: conv });
91
+ });
92
+
93
+ // Dynamic: /api/conversations/:id/export|retry|navigate|forks|edit
94
+ router.get(CONV_ACTION_RE, async (req, res) => {
95
+ const m = req.url.match(CONV_ACTION_RE);
96
+ const [, id, action] = m;
97
+ if (action === 'export') {
98
+ const conv = loadConversation(id);
99
+ if (!conv) return sendError(res, 404, 'Not found');
100
+ const url = new URL(req.url, 'http://localhost');
101
+ const fmt = url.searchParams.get('format') || 'md';
102
+ if (fmt === 'json') {
103
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="nha-chat-${id}.json"` });
104
+ res.end(exportAsJson(conv));
105
+ } else {
106
+ res.writeHead(200, { 'Content-Type': 'text/markdown', 'Content-Disposition': `attachment; filename="nha-chat-${id}.md"` });
107
+ res.end(exportAsMarkdown(conv));
108
+ }
109
+ return;
110
+ }
111
+ if (action === 'forks') {
112
+ const conv = loadConversation(id);
113
+ if (!conv) return sendError(res, 404, 'Not found');
114
+ const messages = getHistory(conv);
115
+ const forks = {};
116
+ for (const msg of messages) {
117
+ const info = getForkInfo(conv, msg.id);
118
+ if (info) forks[msg.id] = info;
119
+ }
120
+ return sendJSON(res, 200, { forks });
121
+ }
122
+ sendError(res, 404, 'Not found');
123
+ });
124
+
125
+ router.post(CONV_ACTION_RE, async (req, res) => {
126
+ const m = req.url.match(CONV_ACTION_RE);
127
+ const [, id, action] = m;
128
+ const conv = loadConversation(id);
129
+ if (!conv) return sendError(res, 404, 'Not found');
130
+ const body = await parseBody(req);
131
+
132
+ if (action === 'retry') {
133
+ const userNodeId = retryMessage(conv, body.assistantNodeId);
134
+ if (!userNodeId) return sendError(res, 400, 'Invalid message');
135
+ return sendJSON(res, 200, { userNodeId, userContent: conv.tree?.[userNodeId]?.content });
136
+ }
137
+ if (action === 'retry-response') {
138
+ const newId = addRetryResponse(conv, body.userNodeId, body.content);
139
+ return sendJSON(res, 200, { nodeId: newId, messages: getHistory(conv) });
140
+ }
141
+ if (action === 'navigate') {
142
+ const ok = navigateFork(conv, body.nodeId, body.direction);
143
+ return sendJSON(res, 200, { ok, messages: getHistory(conv) });
144
+ }
145
+ if (action === 'edit') {
146
+ const ok = editMessage(conv, body.nodeId, body.content);
147
+ return sendJSON(res, 200, { ok, messages: getHistory(conv) });
148
+ }
149
+ sendError(res, 404, 'Not found');
150
+ });
151
+
152
+ // ── Streaming Chat ──────────────────────────────────────────────────────
153
+
154
+ router.post('/api/chat/stream', async (req, res) => {
155
+ const body = await parseBody(req);
156
+ if (!body.message) return sendError(res, 400, 'message required');
157
+
158
+ const config = loadConfig();
159
+ if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
160
+ config.llm.provider = 'nha';
161
+ }
162
+
163
+ const msg = body.message.trim();
164
+ const chatSystemPrompt = await getChatSystemPrompt();
165
+
166
+ // @agent inline routing OR body.agent param (from Agents panel)
167
+ let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
168
+ let effectiveMsg = msg;
169
+
170
+ // body.agent takes priority (Agents view sends { agent: 'saber', message: '...' })
171
+ if (body.agent) {
172
+ const agentFile = path.join(AGENTS_DIR, `${body.agent.toLowerCase()}.mjs`);
173
+ if (fs.existsSync(agentFile)) {
174
+ const parsed = parseAgentFile(fs.readFileSync(agentFile, 'utf-8'), body.agent);
175
+ if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
176
+ }
177
+ // Fallback: use system prompt sent directly by client (from agent card)
178
+ if (!effectiveSystemPrompt && body._agentSystemPrompt) {
179
+ effectiveSystemPrompt = body._agentSystemPrompt;
180
+ }
181
+ } else {
182
+ // @agent inline routing
183
+ const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
184
+ if (atMatch) {
185
+ const agentFile = path.join(AGENTS_DIR, `${atMatch[1].toLowerCase()}.mjs`);
186
+ if (fs.existsSync(agentFile)) {
187
+ const parsed = parseAgentFile(fs.readFileSync(agentFile, 'utf-8'), atMatch[1]);
188
+ if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
189
+ }
190
+ effectiveMsg = atMatch[2];
191
+ }
192
+ }
193
+
194
+ let enrichedPrompt = effectiveSystemPrompt || chatSystemPrompt;
195
+ try { const m = buildMemoryContext('chat', effectiveMsg); if (m) enrichedPrompt = enrichedPrompt + m; } catch {}
196
+ try { const ic = await getImapAccountsContext(); if (ic) enrichedPrompt += ic; } catch {}
197
+
198
+ // Rolling context window
199
+ const rawHistory = (body.history || []).map(h => ({
200
+ role: h.role,
201
+ content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
202
+ }));
203
+ const RECENT = 6;
204
+ const parts = [];
205
+ if (rawHistory.length > RECENT) {
206
+ const older = rawHistory.slice(0, -RECENT);
207
+ const lines = [];
208
+ for (let i = 0; i < older.length; i += 2) {
209
+ const u = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
210
+ const a = older[i+1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
211
+ if (u) lines.push(`- User: "${u.trim()}${u.length >= 150 ? '...' : ''}" → ${a.trim()}${a.length >= 200 ? '...' : ''}`);
212
+ }
213
+ if (lines.length) parts.push(`[CONVERSATION CONTEXT]\n${lines.join('\n')}\n[END CONTEXT]`);
214
+ }
215
+ for (const t of rawHistory.slice(-RECENT)) {
216
+ parts.push(`${t.role === 'user' ? '[User]' : '[Assistant]'} ${t.content.slice(0, 2000)}`);
217
+ }
218
+ parts.push(`[User] ${effectiveMsg}`);
219
+ const userMessage = parts.join('\n\n');
220
+
221
+ // Attachments — handle non-streaming
222
+ if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
223
+ return sendJSON(res, 200, { error: 'attachments_use_regular', redirect: '/api/chat' });
224
+ }
225
+
226
+ // SSE
227
+ res.writeHead(200, {
228
+ 'Content-Type': 'text/event-stream',
229
+ 'Cache-Control': 'no-cache',
230
+ 'Connection': 'keep-alive',
231
+ 'Access-Control-Allow-Origin': '*',
232
+ });
233
+ const sse = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
234
+
235
+ sse('processing', {});
236
+
237
+ let heartbeatInterval = setInterval(() => {
238
+ try { sse('processing', { ts: Date.now() }); } catch {}
239
+ }, 3000);
240
+
241
+ try {
242
+ let fullResponse = '';
243
+ fullResponse = await callLLMStream(config, enrichedPrompt, userMessage, (chunk) => {
244
+ clearInterval(heartbeatInterval);
245
+ heartbeatInterval = null;
246
+ sse('token', { content: chunk });
247
+ });
248
+
249
+ const { textParts, actions } = parseActions(fullResponse);
250
+ const toolResults = [];
251
+
252
+ // Auto-detect intent
253
+ const wantsScreenshot = /screenshot|screen\s*shot|schermo|cattura|foto|immagine/i.test(msg);
254
+ const wantsSearch = /\b(cerca|search|find|look\s*up|ricerca|cercare)\b/i.test(msg);
255
+ if (wantsSearch && !actions.some(a => a.action === 'web_search')) {
256
+ const q = msg.replace(/\b(cerca|search|find|look\s*up|ricerca|cercare|screenshot|screen\s*shot)\b/gi, '').trim();
257
+ if (q.length > 2) actions.push({ action: 'web_search', params: { query: q, screenshot: wantsScreenshot } });
258
+ }
259
+ // domain → browser_open
260
+ for (const a of actions) {
261
+ if (a.action === 'web_search' && /^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(a.params.query?.trim())) {
262
+ a.action = 'browser_open';
263
+ a.params = { url: 'https://' + a.params.query.trim() };
264
+ }
265
+ }
266
+
267
+ for (const { action, params } of actions) {
268
+ if (action === 'web_search' && wantsScreenshot) params.screenshot = true;
269
+ sse('tool', { action, status: 'executing' });
270
+ try {
271
+ const result = await executeTool(action, params, config);
272
+ let resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
273
+ if ((action === 'web_search' || action === 'fetch_url') && resultStr.includes('<')) {
274
+ resultStr = resultStr
275
+ .replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<script[\s\S]*?<\/script>/gi, '')
276
+ .replace(/<[^>]+>/g, ' ').replace(/\s{3,}/g, '\n').trim().slice(0, 6000);
277
+ }
278
+ toolResults.push({ action, result: resultStr });
279
+ sse('tool', { action, status: 'done', result: resultStr.slice(0, 500) });
280
+ } catch (e) {
281
+ toolResults.push({ action, result: `Error: ${e.message}` });
282
+ sse('tool', { action, status: 'error', error: e.message });
283
+ }
284
+ }
285
+
286
+ // Synthesis round if tools ran
287
+ if (toolResults.length > 0) {
288
+ // Strip raw JSON from tool results — present as clean prose summaries
289
+ const cleanResult = (_action, raw) => {
290
+ const s = typeof raw === 'string' ? raw : JSON.stringify(raw);
291
+ // If it's already plain text (web_search returns plain text), return as-is
292
+ if (!s.startsWith('{') && !s.startsWith('[')) return s.slice(0, 4000);
293
+ try {
294
+ const obj = JSON.parse(s);
295
+ if (Array.isArray(obj)) {
296
+ // Array of search results — format as numbered list
297
+ return obj.slice(0, 5).map((r, i) => `${i+1}. ${r.title || r.name || ''}\n ${r.snippet || r.description || r.url || ''}`).join('\n');
298
+ }
299
+ if (obj.results && Array.isArray(obj.results)) {
300
+ return obj.results.slice(0, 5).map((r, i) => `${i+1}. ${r.title || ''}\n ${r.snippet || r.url || ''}`).join('\n');
301
+ }
302
+ if (obj.content) return String(obj.content).slice(0, 4000);
303
+ if (obj.text) return String(obj.text).slice(0, 4000);
304
+ if (obj.snippet) return String(obj.snippet).slice(0, 2000);
305
+ if (obj.error) return `Error: ${obj.error}`;
306
+ // Generic: format key-value pairs as readable text
307
+ return Object.entries(obj).slice(0, 20).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v).slice(0, 200) : v}`).join('\n');
308
+ } catch {
309
+ return s.slice(0, 4000);
310
+ }
311
+ };
312
+ const toolContext = toolResults.map(t => `[${t.action} result]:\n${cleanResult(t.action, t.result)}`).join('\n\n---\n\n');
313
+ const synthesisPrompt = `${enrichedPrompt}\n\n## DATA FROM TOOLS:\n${toolContext}\n\n## STRICT OUTPUT RULES:\n- Write ONLY plain prose or markdown (headers, bullets, bold)\n- NEVER use \`\`\`json, \`\`\`data, or any fenced code block containing data\n- NEVER output raw JSON, arrays, or objects\n- Format numbers/prices as plain text (e.g. "Bitcoin: $103,000")\n- Be concise and human-readable`;
314
+ const synthesisMsg = `${effectiveMsg}\n\nAnswer using ONLY the data above. Plain text/markdown only — zero JSON, zero code blocks.`;
315
+ sse('tool_synthesis', {});
316
+ fullResponse = '';
317
+ fullResponse = await callLLMStream(config, synthesisPrompt, synthesisMsg, (chunk) => {
318
+ sse('token', { content: chunk });
319
+ });
320
+ }
321
+
322
+ // Persist to conversation
323
+ if (body.conversationId) {
324
+ try {
325
+ const conv = loadConversation(body.conversationId);
326
+ if (conv) {
327
+ addMessages(conv, msg, fullResponse);
328
+ }
329
+ } catch {}
330
+ }
331
+
332
+ if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
333
+ sse('done', { content: fullResponse });
334
+ res.write('data: [DONE]\n\n');
335
+ res.end();
336
+ } catch (e) {
337
+ if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
338
+ sse('error', { message: e.message });
339
+ res.end();
340
+ }
341
+ });
342
+
343
+ // POST /api/ask — single-turn non-streaming chat
344
+ router.post('/api/ask', async (req, res) => {
345
+ try {
346
+ const body = await parseBody(req);
347
+ if (!body.message) return sendError(res, 400, 'message required');
348
+ const config = loadConfig();
349
+ const chatSystemPrompt = await getChatSystemPrompt();
350
+ const response = await callLLM(config, chatSystemPrompt, body.message);
351
+ sendJSON(res, 200, { response });
352
+ } catch (e) { sendError(res, 500, e.message); }
353
+ });
354
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Alexandria Collab routes — E2E encrypted messaging
3
+ */
4
+
5
+ import crypto from 'crypto';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { sendJSON, sendError, parseBody } from '../index.mjs';
9
+ import { loadConfig } from '../../config.mjs';
10
+ import { NHA_DIR } from '../../constants.mjs';
11
+
12
+ const ALEX_API = 'https://nothumanallowed.com/api/v1/alexandria';
13
+ const collabDir = path.join(NHA_DIR, 'collab');
14
+ const idFile = path.join(collabDir, 'identity.json');
15
+ const chFile = path.join(collabDir, 'channels.json');
16
+
17
+ function getIdentity() {
18
+ if (fs.existsSync(idFile)) return JSON.parse(fs.readFileSync(idFile, 'utf-8'));
19
+ fs.mkdirSync(collabDir, { recursive: true });
20
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519', {
21
+ publicKeyEncoding: { type: 'spki', format: 'der' },
22
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
23
+ });
24
+ const config = (() => { try { return JSON.parse(fs.readFileSync(path.join(NHA_DIR, 'config.json'), 'utf-8')); } catch { return {}; } })();
25
+ const identity = {
26
+ publicKey: publicKey.toString('base64'),
27
+ privateKey: privateKey.toString('base64'),
28
+ fingerprint: crypto.createHash('sha256').update(publicKey).digest('hex').slice(0, 16),
29
+ displayName: config.profile?.name || 'User',
30
+ };
31
+ fs.writeFileSync(idFile, JSON.stringify(identity, null, 2), { mode: 0o600 });
32
+ return identity;
33
+ }
34
+
35
+ export function register(router) {
36
+ router.get('/api/collab/channels', (_req, res) => {
37
+ try {
38
+ const identity = getIdentity();
39
+ let channels = [];
40
+ if (fs.existsSync(chFile)) try { channels = JSON.parse(fs.readFileSync(chFile, 'utf-8')); } catch {}
41
+ sendJSON(res, 200, { channels, identity: { fingerprint: identity.fingerprint, displayName: identity.displayName } });
42
+ } catch (e) { sendError(res, 500, e.message); }
43
+ });
44
+
45
+ router.post('/api/collab/channels', async (req, res) => {
46
+ try {
47
+ const body = await parseBody(req);
48
+ let channels = [];
49
+ if (fs.existsSync(chFile)) try { channels = JSON.parse(fs.readFileSync(chFile, 'utf-8')); } catch {}
50
+ if (!channels.find(c => c.id === body.id)) {
51
+ channels.push({ id: body.id, name: body.name, active: true, role: body.role || 'member', createdAt: new Date().toISOString() });
52
+ channels.forEach(c => { if (c.id !== body.id) c.active = false; });
53
+ fs.mkdirSync(collabDir, { recursive: true });
54
+ fs.writeFileSync(chFile, JSON.stringify(channels, null, 2), { mode: 0o600 });
55
+ }
56
+ sendJSON(res, 200, { ok: true });
57
+ } catch (e) { sendError(res, 500, e.message); }
58
+ });
59
+
60
+ router.post('/api/collab/send', async (req, res) => {
61
+ try {
62
+ const body = await parseBody(req);
63
+ const identity = getIdentity();
64
+ const r = await fetch(`${ALEX_API}/send`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({ ...body, senderPublicKey: identity.publicKey, senderFingerprint: identity.fingerprint }),
68
+ });
69
+ const data = await r.json();
70
+ sendJSON(res, r.ok ? 200 : 500, data);
71
+ } catch (e) { sendError(res, 500, e.message); }
72
+ });
73
+
74
+ router.get('/api/collab/messages', async (req, res) => {
75
+ try {
76
+ const url = new URL(req.url, 'http://localhost');
77
+ const channelId = url.searchParams.get('channelId');
78
+ const since = url.searchParams.get('since') || '0';
79
+ const r = await fetch(`${ALEX_API}/messages?channelId=${channelId}&since=${since}`);
80
+ const data = await r.json();
81
+ sendJSON(res, r.ok ? 200 : 500, data);
82
+ } catch (e) { sendError(res, 500, e.message); }
83
+ });
84
+
85
+ router.post('/api/collab/create-channel', async (req, res) => {
86
+ try {
87
+ const body = await parseBody(req);
88
+ const r = await fetch(`${ALEX_API}/create-channel`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify(body),
92
+ });
93
+ const data = await r.json();
94
+ sendJSON(res, r.ok ? 200 : 500, data);
95
+ } catch (e) { sendError(res, 500, e.message); }
96
+ });
97
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Config routes — read/write ~/.nha/config.json + version check + weather + health
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { sendJSON, sendError, parseBody } from '../index.mjs';
10
+ import { loadConfig, saveConfig, setConfigValue } from '../../config.mjs';
11
+ import { VERSION } from '../../constants.mjs';
12
+ import { getAuthenticatedProviders, loadTokens } from '../../services/token-store.mjs';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ function _weatherIcon(code) {
17
+ const c = parseInt(code);
18
+ if ([113].includes(c)) return '☀️';
19
+ if ([116].includes(c)) return '⛅';
20
+ if ([119, 122].includes(c)) return '☁️';
21
+ if ([143, 248, 260].includes(c)) return '🌫️';
22
+ if ([176, 263, 266, 293, 296, 299, 302, 305, 308, 353].includes(c)) return '🌧️';
23
+ if ([179, 182, 185, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 362, 365, 368, 371, 374, 377].includes(c)) return '❄️';
24
+ if ([200, 386, 389, 392, 395].includes(c)) return '⚡';
25
+ return '🌡️';
26
+ }
27
+
28
+ export function register(router) {
29
+
30
+ // GET /api/health
31
+ router.get('/api/health', (_req, res) => {
32
+ sendJSON(res, 200, { ok: true, version: VERSION, ts: Date.now() });
33
+ });
34
+
35
+ // GET /api/version/check
36
+ router.get('/api/version/check', async (_req, res) => {
37
+ try {
38
+ const r = await fetch('https://registry.npmjs.org/nothumanallowed/latest');
39
+ const data = await r.json();
40
+ sendJSON(res, 200, { current: VERSION, latest: data.version, hasUpdate: data.version !== VERSION });
41
+ } catch {
42
+ sendJSON(res, 200, { current: VERSION, latest: VERSION, hasUpdate: false });
43
+ }
44
+ });
45
+
46
+ // GET /api/config
47
+ router.get('/api/config', (_req, res) => {
48
+ try {
49
+ const config = loadConfig();
50
+ // Return a flat view that the UI can consume directly
51
+ sendJSON(res, 200, {
52
+ // raw nested (for anything that needs it)
53
+ ...config,
54
+ // flat aliases used by Settings.tsx
55
+ provider: config.llm?.provider || 'nha',
56
+ model: config.llm?.model || '',
57
+ thinking: config.thinking || 'off',
58
+ lang: config.language || config.voice?.language || 'en',
59
+ planTime: config.ops?.planTime || '07:00',
60
+ summaryTime: config.ops?.summaryTime || '18:00',
61
+ meetingAlert: config.ops?.meetingAlertMinutes ?? 30,
62
+ hasTelegram: !!(config.responder?.telegram?.token),
63
+ hasDiscord: !!(config.responder?.discord?.token),
64
+ // Key presence flags (never expose the actual key)
65
+ hasApiKey: !!(config.llm?.apiKey),
66
+ hasOpenaiKey: !!(config.llm?.openaiKey),
67
+ hasGeminiKey: !!(config.llm?.geminiKey),
68
+ hasDeepseekKey: !!(config.llm?.deepseekKey),
69
+ hasGrokKey: !!(config.llm?.grokKey),
70
+ hasMistralKey: !!(config.llm?.mistralKey),
71
+ hasCohereKey: !!(config.llm?.cohereKey),
72
+ // Google / Microsoft — use token-store (encrypted) as source of truth
73
+ hasGoogle: !!((() => { try { return getAuthenticatedProviders().google; } catch { return config.google?.accessToken || config.google?.refreshToken; } })()),
74
+ hasMicrosoft: !!((() => { try { return getAuthenticatedProviders().microsoft; } catch { return config.microsoft?.accessToken || config.microsoft?.refreshToken; } })()),
75
+ googleEmail: (() => { try { return loadTokens('google')?.email || null; } catch { return null; } })(),
76
+ microsoftEmail: (() => { try { return loadTokens('microsoft')?.email || null; } catch { return null; } })(),
77
+ // Profile (safe)
78
+ profile: config.profile || {},
79
+ // Redact sensitive nested fields
80
+ llm: undefined,
81
+ agent: undefined,
82
+ responder: undefined,
83
+ });
84
+ } catch (e) { sendError(res, 500, e.message); }
85
+ });
86
+
87
+ // POST /api/config — set one or more config values
88
+ router.post('/api/config', async (req, res) => {
89
+ try {
90
+ const body = await parseBody(req);
91
+ if (body.key && body.value !== undefined) {
92
+ const ok = setConfigValue(body.key, body.value);
93
+ if (!ok) return sendError(res, 400, `Unknown config key: ${body.key}`);
94
+ return sendJSON(res, 200, { ok: true });
95
+ }
96
+ // Bulk update: { updates: [{key, value}] }
97
+ if (body.updates && Array.isArray(body.updates)) {
98
+ for (const { key, value } of body.updates) setConfigValue(key, value);
99
+ return sendJSON(res, 200, { ok: true });
100
+ }
101
+ sendError(res, 400, 'Expected { key, value } or { updates: [{key,value}] }');
102
+ } catch (e) { sendError(res, 500, e.message); }
103
+ });
104
+
105
+ // GET /api/status — system status (provider, version, platform)
106
+ router.get('/api/status', (_req, res) => {
107
+ try {
108
+ const config = loadConfig();
109
+ sendJSON(res, 200, {
110
+ version: VERSION,
111
+ provider: config.llm?.provider || 'nha',
112
+ platform: process.platform,
113
+ node: process.version,
114
+ uptime: process.uptime(),
115
+ memory: process.memoryUsage(),
116
+ });
117
+ } catch (e) { sendError(res, 500, e.message); }
118
+ });
119
+
120
+ // GET /api/weather?location=<city name OR lat,lon>
121
+ // Uses wttr.in — accepts both "Milan" and "45.46,9.19"
122
+ router.get('/api/weather', async (req, res) => {
123
+ try {
124
+ const url = new URL(req.url, 'http://localhost');
125
+ const loc = url.searchParams.get('location') || '';
126
+ if (!loc) return sendError(res, 400, 'Missing location');
127
+ const encodedLoc = encodeURIComponent(loc);
128
+ const r = await fetch(
129
+ `https://wttr.in/${encodedLoc}?format=j1`,
130
+ { headers: { 'User-Agent': 'nha-ui/1.0' }, signal: AbortSignal.timeout(8000) }
131
+ );
132
+ if (!r.ok) return sendError(res, 502, `Weather service error: ${r.status}`);
133
+ const w = await r.json();
134
+ const cur = w.current_condition?.[0];
135
+ const area = w.nearest_area?.[0];
136
+ if (!cur) return sendError(res, 404, 'No weather data');
137
+ sendJSON(res, 200, {
138
+ tempC: parseFloat(cur.temp_C),
139
+ feelsC: parseFloat(cur.FeelsLikeC),
140
+ humidity: cur.humidity,
141
+ desc: cur.weatherDesc?.[0]?.value || '',
142
+ icon: _weatherIcon(cur.weatherCode),
143
+ city: area?.areaName?.[0]?.value || loc,
144
+ country: area?.country?.[0]?.value || '',
145
+ windKmph: cur.windspeedKmph,
146
+ });
147
+ } catch (e) { sendError(res, 500, e.message); }
148
+ });
149
+
150
+ // POST /api/update-npm — run npm install -g nothumanallowed@latest
151
+ router.post('/api/update-npm', async (_req, res) => {
152
+ const { exec } = await import('child_process');
153
+ exec('npm install -g nothumanallowed@latest', { timeout: 60000 }, (err, stdout, stderr) => {
154
+ if (err) return sendError(res, 500, stderr || err.message);
155
+ sendJSON(res, 200, { ok: true, output: stdout });
156
+ });
157
+ });
158
+
159
+ // GET /api/screenshots/:filename — serve screenshot files from ~/.nha/screenshots/
160
+ router.get(/^\/api\/screenshots\/(?<filename>[^/?]+)/, (req, res) => {
161
+ const filename = req.params.filename ?? '';
162
+ if (!filename || filename.includes('..') || filename.includes('/')) {
163
+ return sendError(res, 400, 'Invalid filename');
164
+ }
165
+ const screenshotsDir = path.join(os.homedir(), '.nha', 'screenshots');
166
+ const abs = path.join(screenshotsDir, filename);
167
+ if (!abs.startsWith(screenshotsDir)) return sendError(res, 400, 'Path traversal rejected');
168
+ if (!fs.existsSync(abs)) return sendError(res, 404, 'Screenshot not found');
169
+ const ext = path.extname(filename).toLowerCase();
170
+ const mime = ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : ext === '.webp' ? 'image/webp' : 'application/octet-stream';
171
+ const data = fs.readFileSync(abs);
172
+ res.writeHead(200, {
173
+ 'Content-Type': mime,
174
+ 'Cache-Control': 'public, max-age=3600',
175
+ 'Access-Control-Allow-Origin': '*',
176
+ });
177
+ res.end(data);
178
+ });
179
+ }