nothumanallowed 2.1.0 → 3.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.
- package/package.json +1 -1
- package/src/cli.mjs +6 -0
- package/src/commands/ui.mjs +632 -0
- package/src/constants.mjs +1 -1
- package/src/services/web-ui.mjs +796 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Ask agents directly, plan your day with 5 specialist agents, manage tasks, connect Gmail + Calendar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { cmdPlan } from './commands/plan.mjs';
|
|
|
13
13
|
import { cmdTasks } from './commands/tasks.mjs';
|
|
14
14
|
import { cmdOps } from './commands/ops.mjs';
|
|
15
15
|
import { cmdChat } from './commands/chat.mjs';
|
|
16
|
+
import { cmdUI } from './commands/ui.mjs';
|
|
16
17
|
import { cmdGoogle } from './commands/google-auth.mjs';
|
|
17
18
|
import { banner, info, ok, warn, fail, C, G, Y, D, W, BOLD, NC, M, B, R } from './ui.mjs';
|
|
18
19
|
|
|
@@ -58,6 +59,9 @@ export async function main(argv) {
|
|
|
58
59
|
case 'chat':
|
|
59
60
|
return cmdChat(args);
|
|
60
61
|
|
|
62
|
+
case 'ui':
|
|
63
|
+
return cmdUI(args);
|
|
64
|
+
|
|
61
65
|
case 'google':
|
|
62
66
|
return cmdGoogle(args);
|
|
63
67
|
|
|
@@ -355,6 +359,8 @@ function cmdHelp() {
|
|
|
355
359
|
console.log(` run "prompt" ${D}--agents saber,zero${NC} Collaborate with specific agents\n`);
|
|
356
360
|
|
|
357
361
|
console.log(` ${C}Daily Operations${NC} ${D}(Gmail + Calendar + Tasks)${NC}`);
|
|
362
|
+
console.log(` ui Open local web dashboard (http://127.0.0.1:3847)`);
|
|
363
|
+
console.log(` ui --port=4000 Custom port ui --no-browser Don't auto-open`);
|
|
358
364
|
console.log(` chat Interactive chat — manage email/calendar/tasks naturally`);
|
|
359
365
|
console.log(` plan Generate daily plan (5 agents analyze your day)`);
|
|
360
366
|
console.log(` plan --refresh Regenerate today's plan`);
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nha ui — Local web interface for NHA operations.
|
|
3
|
+
*
|
|
4
|
+
* Starts a zero-dependency HTTP server on localhost:3847 serving a single-page
|
|
5
|
+
* operations console with REST API endpoints that reuse existing services.
|
|
6
|
+
*
|
|
7
|
+
* Zero npm dependencies — Node.js 22 native http module only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import { exec } from 'child_process';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { loadConfig } from '../config.mjs';
|
|
15
|
+
import { callLLM, callAgent, parseAgentFile } from '../services/llm.mjs';
|
|
16
|
+
import { getUnreadImportant } from '../services/google-gmail.mjs';
|
|
17
|
+
import { getTodayEvents } from '../services/google-calendar.mjs';
|
|
18
|
+
import {
|
|
19
|
+
getTasks,
|
|
20
|
+
addTask,
|
|
21
|
+
completeTask,
|
|
22
|
+
getDayStats,
|
|
23
|
+
} from '../services/task-store.mjs';
|
|
24
|
+
import { runPlanningPipeline } from '../services/ops-pipeline.mjs';
|
|
25
|
+
import { AGENTS, AGENTS_DIR, NHA_DIR, VERSION } from '../constants.mjs';
|
|
26
|
+
import { getHTML } from '../services/web-ui.mjs';
|
|
27
|
+
import { info, ok, fail, warn, C, G, D, NC, BOLD } from '../ui.mjs';
|
|
28
|
+
|
|
29
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PORT = 3847;
|
|
32
|
+
const HOST = '127.0.0.1';
|
|
33
|
+
|
|
34
|
+
// ── Chat system prompt (reused from chat.mjs) ──────────────────────────────
|
|
35
|
+
|
|
36
|
+
const TOOL_DEFINITIONS = `
|
|
37
|
+
You have access to the following tools. When the user's message requires an action,
|
|
38
|
+
output EXACTLY ONE fenced JSON block per action:
|
|
39
|
+
|
|
40
|
+
\`\`\`json
|
|
41
|
+
{"action": "<tool_name>", "params": { ... }}
|
|
42
|
+
\`\`\`
|
|
43
|
+
|
|
44
|
+
You may include conversational text BEFORE or AFTER the JSON block. If no action
|
|
45
|
+
is needed, respond normally without any JSON block.
|
|
46
|
+
|
|
47
|
+
TOOLS:
|
|
48
|
+
|
|
49
|
+
1. gmail_list(query: string, maxResults?: number)
|
|
50
|
+
Search emails. query uses Gmail search syntax.
|
|
51
|
+
|
|
52
|
+
2. gmail_read(messageId: string)
|
|
53
|
+
Read the full body of an email by its ID.
|
|
54
|
+
|
|
55
|
+
3. gmail_send(to: string, subject: string, body: string)
|
|
56
|
+
Send an email. ALWAYS confirm with the user before sending.
|
|
57
|
+
|
|
58
|
+
4. gmail_draft(to: string, subject: string, body: string)
|
|
59
|
+
Create a draft email (safe — does not send).
|
|
60
|
+
|
|
61
|
+
5. gmail_reply(messageId: string, body: string)
|
|
62
|
+
Reply to an existing email thread. ALWAYS confirm before sending.
|
|
63
|
+
|
|
64
|
+
6. calendar_today()
|
|
65
|
+
List all events for today.
|
|
66
|
+
|
|
67
|
+
7. calendar_upcoming(hours?: number)
|
|
68
|
+
List upcoming events in the next N hours (default 2).
|
|
69
|
+
|
|
70
|
+
8. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
|
|
71
|
+
Create a calendar event. start/end are ISO 8601 datetime strings.
|
|
72
|
+
|
|
73
|
+
9. task_list()
|
|
74
|
+
List today's tasks.
|
|
75
|
+
|
|
76
|
+
10. task_add(description: string, priority?: "low"|"medium"|"high"|"critical", due?: string)
|
|
77
|
+
Add a new task for today.
|
|
78
|
+
|
|
79
|
+
11. task_done(id: number)
|
|
80
|
+
Mark a task as completed.
|
|
81
|
+
|
|
82
|
+
RULES:
|
|
83
|
+
- For search/read operations, execute immediately and present results conversationally.
|
|
84
|
+
- For write/send/delete operations, describe what you're about to do and include the JSON block.
|
|
85
|
+
- When presenting results, format them clearly in natural language. Never dump raw JSON to the user.
|
|
86
|
+
- If you need multiple actions in sequence, do them ONE AT A TIME.
|
|
87
|
+
- Dates: today is {{TODAY}}. The user's timezone is {{TIMEZONE}}.
|
|
88
|
+
`.trim();
|
|
89
|
+
|
|
90
|
+
// ── Reuse the executeTool and parseActions logic from chat.mjs ──────────
|
|
91
|
+
|
|
92
|
+
import {
|
|
93
|
+
listMessages,
|
|
94
|
+
getMessage,
|
|
95
|
+
sendEmail,
|
|
96
|
+
createDraft,
|
|
97
|
+
} from '../services/google-gmail.mjs';
|
|
98
|
+
|
|
99
|
+
import {
|
|
100
|
+
getUpcomingEvents,
|
|
101
|
+
createEvent,
|
|
102
|
+
} from '../services/google-calendar.mjs';
|
|
103
|
+
|
|
104
|
+
function parseActions(text) {
|
|
105
|
+
const actions = [];
|
|
106
|
+
const textParts = [];
|
|
107
|
+
const fenceRegex = /```json\s*\n?([\s\S]*?)```/g;
|
|
108
|
+
let lastIndex = 0;
|
|
109
|
+
let match;
|
|
110
|
+
|
|
111
|
+
while ((match = fenceRegex.exec(text)) !== null) {
|
|
112
|
+
const before = text.slice(lastIndex, match.index).trim();
|
|
113
|
+
if (before) textParts.push(before);
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(match[1].trim());
|
|
116
|
+
if (parsed.action && typeof parsed.action === 'string') {
|
|
117
|
+
actions.push({ action: parsed.action, params: parsed.params || {} });
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
textParts.push(match[0]);
|
|
121
|
+
}
|
|
122
|
+
lastIndex = match.index + match[0].length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const trailing = text.slice(lastIndex).trim();
|
|
126
|
+
if (trailing) textParts.push(trailing);
|
|
127
|
+
return { textParts, actions };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function executeTool(action, params, config) {
|
|
131
|
+
switch (action) {
|
|
132
|
+
case 'gmail_list': {
|
|
133
|
+
const query = params.query || 'is:unread';
|
|
134
|
+
const max = params.maxResults || 10;
|
|
135
|
+
const refs = await listMessages(config, query, max);
|
|
136
|
+
if (refs.length === 0) return 'No emails found matching that query.';
|
|
137
|
+
const messages = [];
|
|
138
|
+
for (const ref of refs.slice(0, max)) {
|
|
139
|
+
try { messages.push(await getMessage(config, ref.id)); } catch {}
|
|
140
|
+
}
|
|
141
|
+
return messages.map((m, i) =>
|
|
142
|
+
`${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject} | Date: ${m.date}\n ${m.snippet.slice(0, 120)}`
|
|
143
|
+
).join('\n');
|
|
144
|
+
}
|
|
145
|
+
case 'gmail_read': {
|
|
146
|
+
const msg = await getMessage(config, params.messageId);
|
|
147
|
+
return `From: ${msg.from}\nTo: ${msg.to}\nSubject: ${msg.subject}\nDate: ${msg.date}\n---\n${msg.body.slice(0, 4000)}`;
|
|
148
|
+
}
|
|
149
|
+
case 'gmail_send': {
|
|
150
|
+
await sendEmail(config, params.to, params.subject, params.body);
|
|
151
|
+
return `Email sent to ${params.to} with subject "${params.subject}".`;
|
|
152
|
+
}
|
|
153
|
+
case 'gmail_draft': {
|
|
154
|
+
await createDraft(config, params.to, params.subject, params.body);
|
|
155
|
+
return `Draft created for ${params.to} with subject "${params.subject}".`;
|
|
156
|
+
}
|
|
157
|
+
case 'gmail_reply': {
|
|
158
|
+
const original = await getMessage(config, params.messageId);
|
|
159
|
+
await sendEmail(config, original.from, `Re: ${original.subject}`, params.body, {
|
|
160
|
+
replyToMessageId: original.id,
|
|
161
|
+
threadId: original.threadId,
|
|
162
|
+
});
|
|
163
|
+
return `Reply sent to ${original.from} on thread "${original.subject}".`;
|
|
164
|
+
}
|
|
165
|
+
case 'calendar_today': {
|
|
166
|
+
const events = await getTodayEvents(config);
|
|
167
|
+
if (events.length === 0) return 'No events scheduled for today.';
|
|
168
|
+
return events.map((e, i) => {
|
|
169
|
+
const time = e.isAllDay ? 'All day' : `${fmtTime(e.start)} - ${fmtTime(e.end)}`;
|
|
170
|
+
return `${i + 1}. ${time} — ${e.summary}${e.location ? ' @ ' + e.location : ''}`;
|
|
171
|
+
}).join('\n');
|
|
172
|
+
}
|
|
173
|
+
case 'calendar_upcoming': {
|
|
174
|
+
const hours = params.hours || 2;
|
|
175
|
+
const events = await getUpcomingEvents(config, hours);
|
|
176
|
+
if (events.length === 0) return `No events in the next ${hours} hour(s).`;
|
|
177
|
+
return events.map((e, i) => {
|
|
178
|
+
const time = e.isAllDay ? 'All day' : `${fmtTime(e.start)} - ${fmtTime(e.end)}`;
|
|
179
|
+
return `${i + 1}. ${time} — ${e.summary}`;
|
|
180
|
+
}).join('\n');
|
|
181
|
+
}
|
|
182
|
+
case 'calendar_create': {
|
|
183
|
+
await createEvent(config, {
|
|
184
|
+
summary: params.summary,
|
|
185
|
+
start: params.start,
|
|
186
|
+
end: params.end,
|
|
187
|
+
description: params.description || '',
|
|
188
|
+
attendees: params.attendees || [],
|
|
189
|
+
});
|
|
190
|
+
return `Event "${params.summary}" created for ${fmtTime(params.start)} - ${fmtTime(params.end)}.`;
|
|
191
|
+
}
|
|
192
|
+
case 'task_list': {
|
|
193
|
+
const tasks = getTasks();
|
|
194
|
+
if (tasks.length === 0) return 'No tasks for today.';
|
|
195
|
+
return tasks.map(t =>
|
|
196
|
+
`#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}${t.due ? ' (due: ' + t.due + ')' : ''}`
|
|
197
|
+
).join('\n');
|
|
198
|
+
}
|
|
199
|
+
case 'task_add': {
|
|
200
|
+
const task = addTask({
|
|
201
|
+
description: params.description,
|
|
202
|
+
priority: params.priority || 'medium',
|
|
203
|
+
due: params.due || null,
|
|
204
|
+
source: 'chat',
|
|
205
|
+
});
|
|
206
|
+
return `Task #${task.id} added: "${task.description}" [${task.priority}]`;
|
|
207
|
+
}
|
|
208
|
+
case 'task_done': {
|
|
209
|
+
const success = completeTask(params.id);
|
|
210
|
+
return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
return `Unknown action: ${action}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fmtTime(isoStr) {
|
|
218
|
+
try {
|
|
219
|
+
return new Date(isoStr).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
220
|
+
} catch { return isoStr; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Agent loader ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function loadAgentCards() {
|
|
226
|
+
const cards = [];
|
|
227
|
+
for (const name of AGENTS) {
|
|
228
|
+
const file = path.join(AGENTS_DIR, `${name}.mjs`);
|
|
229
|
+
let card = { name, category: 'agent', tagline: '' };
|
|
230
|
+
try {
|
|
231
|
+
if (fs.existsSync(file)) {
|
|
232
|
+
const source = fs.readFileSync(file, 'utf-8');
|
|
233
|
+
const parsed = parseAgentFile(source, name);
|
|
234
|
+
card = { name, ...parsed.card };
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
cards.push(card);
|
|
238
|
+
}
|
|
239
|
+
return cards;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Plan file loader ──────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function loadTodayPlan() {
|
|
245
|
+
const dateStr = new Date().toISOString().split('T')[0];
|
|
246
|
+
const planFile = path.join(NHA_DIR, 'ops', 'plans', `${dateStr}.json`);
|
|
247
|
+
if (fs.existsSync(planFile)) {
|
|
248
|
+
try { return JSON.parse(fs.readFileSync(planFile, 'utf-8')); }
|
|
249
|
+
catch { return null; }
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── HTTP Helpers ──────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function sendJSON(res, statusCode, data) {
|
|
257
|
+
const body = JSON.stringify(data);
|
|
258
|
+
res.writeHead(statusCode, {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
'Access-Control-Allow-Origin': '*',
|
|
261
|
+
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
|
|
262
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
263
|
+
'Cache-Control': 'no-cache',
|
|
264
|
+
});
|
|
265
|
+
res.end(body);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function sendHTML(res, html) {
|
|
269
|
+
res.writeHead(200, {
|
|
270
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
271
|
+
'Cache-Control': 'no-cache',
|
|
272
|
+
});
|
|
273
|
+
res.end(html);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseBody(req) {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
let size = 0;
|
|
280
|
+
const MAX = 1_048_576; // 1 MB
|
|
281
|
+
req.on('data', chunk => {
|
|
282
|
+
size += chunk.length;
|
|
283
|
+
if (size > MAX) { reject(new Error('Body too large')); req.destroy(); return; }
|
|
284
|
+
chunks.push(chunk);
|
|
285
|
+
});
|
|
286
|
+
req.on('end', () => {
|
|
287
|
+
try {
|
|
288
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
289
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
290
|
+
} catch (e) { reject(e); }
|
|
291
|
+
});
|
|
292
|
+
req.on('error', reject);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Open browser ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function openBrowser(url) {
|
|
299
|
+
const platform = process.platform;
|
|
300
|
+
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
|
|
301
|
+
exec(`${cmd} ${url}`, () => {});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Request logger ──────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function logRequest(method, url, statusCode, durationMs) {
|
|
307
|
+
const color = statusCode < 400 ? G : '\x1b[0;31m';
|
|
308
|
+
console.log(` ${D}${new Date().toISOString().slice(11,19)}${NC} ${color}${statusCode}${NC} ${method.padEnd(6)} ${url} ${D}${durationMs}ms${NC}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Server ───────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
export async function cmdUI(args) {
|
|
314
|
+
// Parse port flag
|
|
315
|
+
let port = DEFAULT_PORT;
|
|
316
|
+
let noBrowser = false;
|
|
317
|
+
for (const arg of args) {
|
|
318
|
+
if (arg.startsWith('--port=')) {
|
|
319
|
+
port = parseInt(arg.split('=')[1], 10) || DEFAULT_PORT;
|
|
320
|
+
} else if (arg === '--no-browser') {
|
|
321
|
+
noBrowser = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const config = loadConfig();
|
|
326
|
+
const htmlPage = getHTML(port);
|
|
327
|
+
|
|
328
|
+
// Pre-load agent cards once at startup
|
|
329
|
+
const agentCards = loadAgentCards();
|
|
330
|
+
|
|
331
|
+
// Chat session state (persists across requests while server is running)
|
|
332
|
+
const chatSystemPrompt = (() => {
|
|
333
|
+
const today = new Date().toISOString().split('T')[0];
|
|
334
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
335
|
+
let prompt = TOOL_DEFINITIONS.replace('{{TODAY}}', today).replace('{{TIMEZONE}}', tz);
|
|
336
|
+
prompt += `\n\nYou are NHA Chat, a personal operations assistant inside the NotHumanAllowed web UI. ` +
|
|
337
|
+
`You help the user manage their emails, calendar, and tasks through natural conversation. ` +
|
|
338
|
+
`Be concise, helpful, and proactive. When presenting data, format it clearly. ` +
|
|
339
|
+
`Never output raw JSON to the user.`;
|
|
340
|
+
return prompt;
|
|
341
|
+
})();
|
|
342
|
+
|
|
343
|
+
// ── Route Handlers ──────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
async function handleRequest(req, res) {
|
|
346
|
+
const start = Date.now();
|
|
347
|
+
const url = new URL(req.url, `http://${HOST}:${port}`);
|
|
348
|
+
const pathname = url.pathname;
|
|
349
|
+
const method = req.method;
|
|
350
|
+
|
|
351
|
+
// CORS preflight
|
|
352
|
+
if (method === 'OPTIONS') {
|
|
353
|
+
res.writeHead(204, {
|
|
354
|
+
'Access-Control-Allow-Origin': '*',
|
|
355
|
+
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
|
|
356
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
357
|
+
});
|
|
358
|
+
res.end();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
// ── Serve HTML page ─────────────────────────────────────────────
|
|
364
|
+
if (method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
|
|
365
|
+
sendHTML(res, htmlPage);
|
|
366
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Favicon (no-content) ──────────────────────────────────────────
|
|
371
|
+
if (pathname === '/favicon.ico') {
|
|
372
|
+
res.writeHead(204);
|
|
373
|
+
res.end();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── API Routes ────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
// GET /api/status
|
|
380
|
+
if (method === 'GET' && pathname === '/api/status') {
|
|
381
|
+
sendJSON(res, 200, {
|
|
382
|
+
connected: true,
|
|
383
|
+
version: VERSION,
|
|
384
|
+
provider: config.llm.provider,
|
|
385
|
+
hasApiKey: !!config.llm.apiKey,
|
|
386
|
+
hasGoogle: !!config.google?.clientId,
|
|
387
|
+
agentName: config.agent?.name || null,
|
|
388
|
+
timestamp: new Date().toISOString(),
|
|
389
|
+
});
|
|
390
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// GET /api/emails
|
|
395
|
+
if (method === 'GET' && pathname === '/api/emails') {
|
|
396
|
+
try {
|
|
397
|
+
const emails = await getUnreadImportant(config, 20);
|
|
398
|
+
sendJSON(res, 200, { emails });
|
|
399
|
+
} catch (e) {
|
|
400
|
+
sendJSON(res, 200, { emails: [], error: e.message });
|
|
401
|
+
}
|
|
402
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// GET /api/calendar
|
|
407
|
+
if (method === 'GET' && pathname === '/api/calendar') {
|
|
408
|
+
try {
|
|
409
|
+
const events = await getTodayEvents(config);
|
|
410
|
+
sendJSON(res, 200, { events });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
sendJSON(res, 200, { events: [], error: e.message });
|
|
413
|
+
}
|
|
414
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// GET /api/tasks
|
|
419
|
+
if (method === 'GET' && pathname === '/api/tasks') {
|
|
420
|
+
const tasks = getTasks();
|
|
421
|
+
const stats = getDayStats();
|
|
422
|
+
sendJSON(res, 200, { tasks, stats });
|
|
423
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// POST /api/tasks
|
|
428
|
+
if (method === 'POST' && pathname === '/api/tasks') {
|
|
429
|
+
const body = await parseBody(req);
|
|
430
|
+
if (!body.description) {
|
|
431
|
+
sendJSON(res, 400, { error: 'description required' });
|
|
432
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const task = addTask({
|
|
436
|
+
description: body.description,
|
|
437
|
+
priority: body.priority || 'medium',
|
|
438
|
+
due: body.due || null,
|
|
439
|
+
source: 'web-ui',
|
|
440
|
+
});
|
|
441
|
+
sendJSON(res, 201, { task });
|
|
442
|
+
logRequest(method, pathname, 201, Date.now() - start);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// PATCH /api/tasks/:id/done
|
|
447
|
+
const taskDoneMatch = pathname.match(/^\/api\/tasks\/(\d+)\/done$/);
|
|
448
|
+
if (method === 'PATCH' && taskDoneMatch) {
|
|
449
|
+
const taskId = parseInt(taskDoneMatch[1], 10);
|
|
450
|
+
const success = completeTask(taskId);
|
|
451
|
+
sendJSON(res, 200, { ok: success, id: taskId });
|
|
452
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// GET /api/plan
|
|
457
|
+
if (method === 'GET' && pathname === '/api/plan') {
|
|
458
|
+
const plan = loadTodayPlan();
|
|
459
|
+
sendJSON(res, 200, { plan });
|
|
460
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// POST /api/plan/refresh
|
|
465
|
+
if (method === 'POST' && pathname === '/api/plan/refresh') {
|
|
466
|
+
try {
|
|
467
|
+
const plan = await runPlanningPipeline(config, { refresh: true });
|
|
468
|
+
sendJSON(res, 200, { plan });
|
|
469
|
+
} catch (e) {
|
|
470
|
+
sendJSON(res, 500, { error: e.message });
|
|
471
|
+
}
|
|
472
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// POST /api/chat
|
|
477
|
+
if (method === 'POST' && pathname === '/api/chat') {
|
|
478
|
+
const body = await parseBody(req);
|
|
479
|
+
if (!body.message) {
|
|
480
|
+
sendJSON(res, 400, { error: 'message required' });
|
|
481
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!config.llm.apiKey) {
|
|
486
|
+
sendJSON(res, 200, { response: 'No API key configured. Run: nha config set key YOUR_KEY', error: 'no_api_key' });
|
|
487
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Build message with history
|
|
492
|
+
const history = body.history || [];
|
|
493
|
+
const parts = [];
|
|
494
|
+
for (const turn of history) {
|
|
495
|
+
const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
|
|
496
|
+
parts.push(`${prefix} ${turn.content}`);
|
|
497
|
+
}
|
|
498
|
+
parts.push(`[User] ${body.message}`);
|
|
499
|
+
const userMessage = parts.join('\n\n');
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const response = await callLLM(config, chatSystemPrompt, userMessage);
|
|
503
|
+
const { textParts, actions } = parseActions(response);
|
|
504
|
+
const textResponse = textParts.join('\n\n');
|
|
505
|
+
|
|
506
|
+
// Execute non-destructive tool actions automatically
|
|
507
|
+
let toolResult = null;
|
|
508
|
+
if (actions.length > 0) {
|
|
509
|
+
const { action, params } = actions[0];
|
|
510
|
+
// Skip destructive actions in web UI for safety (gmail_send, gmail_reply, calendar_create)
|
|
511
|
+
const safeActions = new Set([
|
|
512
|
+
'gmail_list', 'gmail_read', 'gmail_draft',
|
|
513
|
+
'calendar_today', 'calendar_upcoming',
|
|
514
|
+
'task_list', 'task_add', 'task_done',
|
|
515
|
+
]);
|
|
516
|
+
if (safeActions.has(action)) {
|
|
517
|
+
try {
|
|
518
|
+
toolResult = await executeTool(action, params, config);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
toolResult = `Error: ${e.message}`;
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
toolResult = `[Action "${action}" requires confirmation. Use nha chat in terminal for write operations.]`;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
sendJSON(res, 200, { response: textResponse, toolResult, actions });
|
|
528
|
+
} catch (e) {
|
|
529
|
+
sendJSON(res, 200, { response: null, error: e.message });
|
|
530
|
+
}
|
|
531
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// GET /api/agents
|
|
536
|
+
if (method === 'GET' && pathname === '/api/agents') {
|
|
537
|
+
sendJSON(res, 200, { agents: agentCards });
|
|
538
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// POST /api/ask
|
|
543
|
+
if (method === 'POST' && pathname === '/api/ask') {
|
|
544
|
+
const body = await parseBody(req);
|
|
545
|
+
if (!body.agent || !body.prompt) {
|
|
546
|
+
sendJSON(res, 400, { error: 'agent and prompt required' });
|
|
547
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (!config.llm.apiKey) {
|
|
552
|
+
sendJSON(res, 200, { response: null, error: 'No API key configured.' });
|
|
553
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!AGENTS.includes(body.agent)) {
|
|
558
|
+
sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
|
|
559
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const response = await callAgent(config, body.agent, body.prompt);
|
|
565
|
+
sendJSON(res, 200, { response, agent: body.agent });
|
|
566
|
+
} catch (e) {
|
|
567
|
+
sendJSON(res, 200, { response: null, error: e.message });
|
|
568
|
+
}
|
|
569
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── 404 ──────────────────────────────────────────────────────────
|
|
574
|
+
sendJSON(res, 404, { error: 'Not found' });
|
|
575
|
+
logRequest(method, pathname, 404, Date.now() - start);
|
|
576
|
+
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error(` \x1b[0;31mServer error:\x1b[0m`, err.message);
|
|
579
|
+
try {
|
|
580
|
+
sendJSON(res, 500, { error: 'Internal server error' });
|
|
581
|
+
} catch {}
|
|
582
|
+
logRequest(method, pathname, 500, Date.now() - start);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Start Server ────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
const server = http.createServer(handleRequest);
|
|
589
|
+
|
|
590
|
+
server.on('error', (err) => {
|
|
591
|
+
if (err.code === 'EADDRINUSE') {
|
|
592
|
+
fail(`Port ${port} is already in use. Try: nha ui --port=${port + 1}`);
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
fail(`Server error: ${err.message}`);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
server.listen(port, HOST, () => {
|
|
600
|
+
const url = `http://${HOST}:${port}`;
|
|
601
|
+
|
|
602
|
+
console.log('');
|
|
603
|
+
console.log(` ${BOLD}${C}NHA Local Operations Console${NC}`);
|
|
604
|
+
console.log(` ${D}Zero-dependency web interface for your daily ops${NC}`);
|
|
605
|
+
console.log('');
|
|
606
|
+
console.log(` ${G}Server running at:${NC} ${url}`);
|
|
607
|
+
console.log(` ${D}Provider:${NC} ${config.llm.provider || 'not set'}`);
|
|
608
|
+
console.log(` ${D}API Key:${NC} ${config.llm.apiKey ? config.llm.apiKey.slice(0, 12) + '...' : '\x1b[0;31mnot set\x1b[0m'}`);
|
|
609
|
+
console.log(` ${D}Agents loaded:${NC} ${agentCards.length}`);
|
|
610
|
+
console.log('');
|
|
611
|
+
console.log(` ${D}Press Ctrl+C to stop${NC}`);
|
|
612
|
+
console.log('');
|
|
613
|
+
|
|
614
|
+
if (!noBrowser) {
|
|
615
|
+
openBrowser(url);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Graceful shutdown
|
|
620
|
+
const shutdown = () => {
|
|
621
|
+
console.log(`\n ${D}Shutting down...${NC}`);
|
|
622
|
+
server.close(() => {
|
|
623
|
+
console.log(` ${D}Server stopped.${NC}\n`);
|
|
624
|
+
process.exit(0);
|
|
625
|
+
});
|
|
626
|
+
// Force exit after 3s if connections hang
|
|
627
|
+
setTimeout(() => process.exit(0), 3000);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
process.on('SIGINT', shutdown);
|
|
631
|
+
process.on('SIGTERM', shutdown);
|
|
632
|
+
}
|
package/src/constants.mjs
CHANGED