nothumanallowed 4.0.2 → 5.0.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/cli.mjs +70 -2
- package/src/commands/chat.mjs +2 -6
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +6 -2
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +23 -0
- package/src/constants.mjs +2 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +159 -5
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/web-ui.mjs +238 -13
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs as detached child process. Polls every 5 min (mail), 15 min (calendar).
|
|
5
5
|
* Generates daily plan at configured time. Sends notifications.
|
|
6
|
+
*
|
|
7
|
+
* WebSocket server on port 3848 broadcasts real-time events to connected
|
|
8
|
+
* clients (nha ui dashboard, etc.) when new emails arrive or meetings approach.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import fs from 'fs';
|
|
9
12
|
import path from 'path';
|
|
13
|
+
import http from 'http';
|
|
14
|
+
import crypto from 'crypto';
|
|
10
15
|
import { spawn } from 'child_process';
|
|
11
16
|
import { NHA_DIR } from '../constants.mjs';
|
|
12
17
|
import { loadConfig } from '../config.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import { getUnreadImportant } from './google-gmail.mjs';
|
|
15
|
-
import { getUpcomingEvents } from './google-calendar.mjs';
|
|
18
|
+
import { hasMailProvider, getUnreadImportant, getUpcomingEvents } from './mail-router.mjs';
|
|
16
19
|
import { notify } from './notification.mjs';
|
|
17
20
|
import { callAgent } from './llm.mjs';
|
|
18
21
|
import { runPlanningPipeline } from './ops-pipeline.mjs';
|
|
@@ -21,6 +24,7 @@ const DAEMON_DIR = path.join(NHA_DIR, 'ops', 'daemon');
|
|
|
21
24
|
const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
|
|
22
25
|
const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
|
|
23
26
|
const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
|
|
27
|
+
const WS_PORT = 3848;
|
|
24
28
|
|
|
25
29
|
// ── Daemon Control ─────────────────────────────────────────────────────────
|
|
26
30
|
|
|
@@ -103,6 +107,117 @@ export function getDaemonStatus() {
|
|
|
103
107
|
return { running, pid, ...state };
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// ── WebSocket Server (RFC 6455, zero-dependency) ────────────────────────────
|
|
111
|
+
|
|
112
|
+
const wsClients = new Set();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Minimal WebSocket handshake and frame implementation.
|
|
116
|
+
* Implements just enough of RFC 6455 for text-frame broadcast.
|
|
117
|
+
*/
|
|
118
|
+
function startWebSocketServer() {
|
|
119
|
+
const server = http.createServer((req, res) => {
|
|
120
|
+
if (req.url === '/health') {
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
122
|
+
res.end(JSON.stringify({ ok: true, clients: wsClients.size }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
res.writeHead(404);
|
|
126
|
+
res.end();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
server.on('upgrade', (req, socket, head) => {
|
|
130
|
+
const key = req.headers['sec-websocket-key'];
|
|
131
|
+
if (!key) { socket.destroy(); return; }
|
|
132
|
+
|
|
133
|
+
const acceptKey = crypto
|
|
134
|
+
.createHash('sha1')
|
|
135
|
+
.update(key + '258EAFA5-E914-47DA-95CA-5AB5DC86C11B')
|
|
136
|
+
.digest('base64');
|
|
137
|
+
|
|
138
|
+
socket.write(
|
|
139
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
140
|
+
'Upgrade: websocket\r\n' +
|
|
141
|
+
'Connection: Upgrade\r\n' +
|
|
142
|
+
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
|
143
|
+
'\r\n'
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
wsClients.add(socket);
|
|
147
|
+
log(`WebSocket client connected (${wsClients.size} total)`);
|
|
148
|
+
|
|
149
|
+
socket.on('data', (buf) => {
|
|
150
|
+
if (buf.length < 2) return;
|
|
151
|
+
const opcode = buf[0] & 0x0f;
|
|
152
|
+
if (opcode === 0x08) { wsClients.delete(socket); socket.end(); return; }
|
|
153
|
+
if (opcode === 0x09) {
|
|
154
|
+
const pong = Buffer.alloc(2);
|
|
155
|
+
pong[0] = 0x8a;
|
|
156
|
+
pong[1] = 0;
|
|
157
|
+
socket.write(pong);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
socket.on('close', () => {
|
|
162
|
+
wsClients.delete(socket);
|
|
163
|
+
log(`WebSocket client disconnected (${wsClients.size} remaining)`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
socket.on('error', () => { wsClients.delete(socket); });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
server.on('error', (err) => {
|
|
170
|
+
if (err.code === 'EADDRINUSE') {
|
|
171
|
+
log(`WebSocket port ${WS_PORT} already in use — skipping WS server`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
log(`WebSocket server error: ${err.message}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.listen(WS_PORT, '127.0.0.1', () => {
|
|
178
|
+
log(`WebSocket server listening on 127.0.0.1:${WS_PORT}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return server;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Encode a string into a WebSocket text frame (RFC 6455).
|
|
186
|
+
*/
|
|
187
|
+
function encodeWSFrame(text) {
|
|
188
|
+
const data = Buffer.from(text, 'utf-8');
|
|
189
|
+
const len = data.length;
|
|
190
|
+
let header;
|
|
191
|
+
if (len < 126) {
|
|
192
|
+
header = Buffer.alloc(2);
|
|
193
|
+
header[0] = 0x81;
|
|
194
|
+
header[1] = len;
|
|
195
|
+
} else if (len < 65536) {
|
|
196
|
+
header = Buffer.alloc(4);
|
|
197
|
+
header[0] = 0x81;
|
|
198
|
+
header[1] = 126;
|
|
199
|
+
header.writeUInt16BE(len, 2);
|
|
200
|
+
} else {
|
|
201
|
+
header = Buffer.alloc(10);
|
|
202
|
+
header[0] = 0x81;
|
|
203
|
+
header[1] = 127;
|
|
204
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
205
|
+
}
|
|
206
|
+
return Buffer.concat([header, data]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Broadcast a JSON message to all connected WebSocket clients.
|
|
211
|
+
*/
|
|
212
|
+
function wsBroadcast(payload) {
|
|
213
|
+
const frame = encodeWSFrame(JSON.stringify(payload));
|
|
214
|
+
for (const socket of wsClients) {
|
|
215
|
+
try { socket.write(frame); } catch { wsClients.delete(socket); }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { wsBroadcast, WS_PORT };
|
|
220
|
+
|
|
106
221
|
// ── Daemon Loop (runs in background process) ───────────────────────────────
|
|
107
222
|
|
|
108
223
|
function saveState(state) {
|
|
@@ -119,11 +234,15 @@ async function daemonLoop() {
|
|
|
119
234
|
log('NHA PAO Daemon started');
|
|
120
235
|
const config = loadConfig();
|
|
121
236
|
|
|
237
|
+
// Start WebSocket server for real-time push notifications
|
|
238
|
+
startWebSocketServer();
|
|
239
|
+
|
|
122
240
|
const state = {
|
|
123
241
|
startedAt: new Date().toISOString(),
|
|
124
242
|
lastMailCheck: null,
|
|
125
243
|
lastCalendarCheck: null,
|
|
126
244
|
lastPlanGenerated: null,
|
|
245
|
+
wsPort: WS_PORT,
|
|
127
246
|
errors: 0,
|
|
128
247
|
};
|
|
129
248
|
saveState(state);
|
|
@@ -141,8 +260,7 @@ async function daemonLoop() {
|
|
|
141
260
|
// Main loop
|
|
142
261
|
setInterval(async () => {
|
|
143
262
|
const now = Date.now();
|
|
144
|
-
|
|
145
|
-
if (!tokens) return; // not authenticated
|
|
263
|
+
if (!hasMailProvider()) return; // not authenticated
|
|
146
264
|
|
|
147
265
|
// ── Mail check ─────────────────────────────────────────
|
|
148
266
|
if (now - lastMailCheck > MAIL_INTERVAL) {
|
|
@@ -154,6 +272,19 @@ async function daemonLoop() {
|
|
|
154
272
|
for (const email of newEmails) {
|
|
155
273
|
knownEmailIds.add(email.id);
|
|
156
274
|
|
|
275
|
+
// Broadcast new email event to WebSocket clients
|
|
276
|
+
wsBroadcast({
|
|
277
|
+
type: 'new_email',
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
data: {
|
|
280
|
+
id: email.id,
|
|
281
|
+
from: email.from,
|
|
282
|
+
subject: email.subject,
|
|
283
|
+
snippet: (email.snippet || '').slice(0, 120),
|
|
284
|
+
isImportant: email.isImportant,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
157
288
|
// SABER quick scan for suspicious emails
|
|
158
289
|
if (email.urls.length > 0 || email.from.includes('paypal') || email.from.includes('bank') || email.subject.toLowerCase().includes('urgent')) {
|
|
159
290
|
try {
|
|
@@ -162,6 +293,11 @@ async function daemonLoop() {
|
|
|
162
293
|
);
|
|
163
294
|
if (scanResult.toUpperCase().includes('FLAGGED')) {
|
|
164
295
|
await notify('Security Alert', `Suspicious email from ${email.from}: ${email.subject}\n${scanResult}`, config);
|
|
296
|
+
wsBroadcast({
|
|
297
|
+
type: 'security_alert',
|
|
298
|
+
timestamp: new Date().toISOString(),
|
|
299
|
+
data: { from: email.from, subject: email.subject, reason: scanResult },
|
|
300
|
+
});
|
|
165
301
|
}
|
|
166
302
|
} catch { /* non-fatal */ }
|
|
167
303
|
}
|
|
@@ -192,6 +328,19 @@ async function daemonLoop() {
|
|
|
192
328
|
const minutesUntil = (eventStart - now) / 60000;
|
|
193
329
|
|
|
194
330
|
if (minutesUntil > 0 && minutesUntil <= MEETING_ALERT) {
|
|
331
|
+
// Broadcast meeting alert to WebSocket clients
|
|
332
|
+
wsBroadcast({
|
|
333
|
+
type: 'meeting_alert',
|
|
334
|
+
timestamp: new Date().toISOString(),
|
|
335
|
+
data: {
|
|
336
|
+
summary: event.summary,
|
|
337
|
+
start: event.start,
|
|
338
|
+
minutesUntil: Math.round(minutesUntil),
|
|
339
|
+
location: event.location || null,
|
|
340
|
+
hangoutLink: event.hangoutLink || null,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
195
344
|
// Generate quick brief with HERALD
|
|
196
345
|
try {
|
|
197
346
|
const brief = await callAgent(config, 'herald',
|
|
@@ -226,6 +375,11 @@ async function daemonLoop() {
|
|
|
226
375
|
state.lastPlanGenerated = new Date().toISOString();
|
|
227
376
|
saveState(state);
|
|
228
377
|
await notify('Daily Plan Ready', `Your plan for ${todayStr} is ready. Run "nha plan --show" to view.`, config);
|
|
378
|
+
wsBroadcast({
|
|
379
|
+
type: 'plan_ready',
|
|
380
|
+
timestamp: new Date().toISOString(),
|
|
381
|
+
data: { date: todayStr },
|
|
382
|
+
});
|
|
229
383
|
log('Daily plan generated');
|
|
230
384
|
} catch (err) {
|
|
231
385
|
log(`Plan generation error: ${err.message}`);
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import fs from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import { callAgent, callLLM } from './llm.mjs';
|
|
18
|
-
import { getUnreadImportant, getTodayEmails } from './
|
|
19
|
-
import { getTodayEvents, getUpcomingEvents } from './
|
|
18
|
+
import { getUnreadImportant, getTodayEmails } from './mail-router.mjs';
|
|
19
|
+
import { getTodayEvents, getUpcomingEvents } from './mail-router.mjs';
|
|
20
20
|
import { getTasks, bulkAddTasks } from './task-store.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { hasMailProvider } from './mail-router.mjs';
|
|
22
22
|
import { NHA_DIR } from '../constants.mjs';
|
|
23
23
|
import { info, ok, fail, warn, D, C, G, Y, W, BOLD, NC } from '../ui.mjs';
|
|
24
24
|
|
|
@@ -47,8 +47,7 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const startTime = Date.now();
|
|
50
|
-
const
|
|
51
|
-
const hasGoogle = !!tokens;
|
|
50
|
+
const hasMail = hasMailProvider();
|
|
52
51
|
|
|
53
52
|
console.log(`\n ${BOLD}NHA Daily Plan — ${dateStr}${NC}`);
|
|
54
53
|
console.log(` ${D}5 specialist agents analyzing your day${NC}\n`);
|
|
@@ -60,7 +59,7 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
60
59
|
let events = [];
|
|
61
60
|
const tasks = getTasks(dateStr);
|
|
62
61
|
|
|
63
|
-
if (
|
|
62
|
+
if (hasMail) {
|
|
64
63
|
try {
|
|
65
64
|
[emails, events] = await Promise.all([
|
|
66
65
|
getUnreadImportant(config, 30),
|
|
@@ -68,11 +67,11 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
68
67
|
]);
|
|
69
68
|
ok(`${emails.length} emails, ${events.length} events, ${tasks.length} tasks`);
|
|
70
69
|
} catch (err) {
|
|
71
|
-
warn(`
|
|
70
|
+
warn(`Mail API error: ${err.message}`);
|
|
72
71
|
info('Continuing with tasks only...');
|
|
73
72
|
}
|
|
74
73
|
} else {
|
|
75
|
-
warn('
|
|
74
|
+
warn('No mail provider connected. Using tasks only. Run "nha google auth" or "nha microsoft auth" to connect.');
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// Build context for agents
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Encrypted token storage — AES-256-GCM with machine-derived key.
|
|
3
|
-
*
|
|
3
|
+
* Supports multiple providers (google, microsoft) via separate encrypted files.
|
|
4
|
+
* Stores OAuth tokens at ~/.nha/ops/tokens-<provider>.enc
|
|
5
|
+
* Legacy single-file (~/.nha/ops/tokens.enc) is treated as 'google' for backward compat.
|
|
4
6
|
* Zero dependencies — uses Node.js native crypto.
|
|
5
7
|
*/
|
|
6
8
|
|
|
@@ -11,7 +13,15 @@ import path from 'path';
|
|
|
11
13
|
import { NHA_DIR } from '../constants.mjs';
|
|
12
14
|
|
|
13
15
|
const OPS_DIR = path.join(NHA_DIR, 'ops');
|
|
14
|
-
const
|
|
16
|
+
const LEGACY_TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
|
|
17
|
+
|
|
18
|
+
/** Get tokens file path for a given provider */
|
|
19
|
+
function getTokensFile(provider) {
|
|
20
|
+
if (!provider || provider === 'google') {
|
|
21
|
+
return LEGACY_TOKENS_FILE; // backward compatible — Google uses the original file
|
|
22
|
+
}
|
|
23
|
+
return path.join(OPS_DIR, `tokens-${provider}.enc`);
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
/** Derive encryption key from machine-specific fingerprint */
|
|
17
27
|
function deriveKey() {
|
|
@@ -49,21 +59,25 @@ function decrypt(envelope) {
|
|
|
49
59
|
/**
|
|
50
60
|
* Save OAuth tokens (encrypted).
|
|
51
61
|
* @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
|
|
62
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
52
63
|
*/
|
|
53
|
-
export function saveTokens(tokens) {
|
|
64
|
+
export function saveTokens(tokens, provider) {
|
|
54
65
|
fs.mkdirSync(OPS_DIR, { recursive: true });
|
|
66
|
+
const file = getTokensFile(provider);
|
|
55
67
|
const envelope = encrypt(tokens);
|
|
56
|
-
fs.writeFileSync(
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(envelope, null, 2), { mode: 0o600 });
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* Load OAuth tokens (decrypted).
|
|
73
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
61
74
|
* @returns {object|null} tokens or null if not stored
|
|
62
75
|
*/
|
|
63
|
-
export function loadTokens() {
|
|
64
|
-
|
|
76
|
+
export function loadTokens(provider) {
|
|
77
|
+
const file = getTokensFile(provider);
|
|
78
|
+
if (!fs.existsSync(file)) return null;
|
|
65
79
|
try {
|
|
66
|
-
const envelope = JSON.parse(fs.readFileSync(
|
|
80
|
+
const envelope = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
67
81
|
return decrypt(envelope);
|
|
68
82
|
} catch {
|
|
69
83
|
return null;
|
|
@@ -72,9 +86,11 @@ export function loadTokens() {
|
|
|
72
86
|
|
|
73
87
|
/**
|
|
74
88
|
* Delete stored tokens.
|
|
89
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
75
90
|
*/
|
|
76
|
-
export function deleteTokens() {
|
|
77
|
-
|
|
91
|
+
export function deleteTokens(provider) {
|
|
92
|
+
const file = getTokensFile(provider);
|
|
93
|
+
if (fs.existsSync(file)) fs.rmSync(file);
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
/**
|
|
@@ -88,7 +104,7 @@ export function isExpired(tokens) {
|
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/**
|
|
91
|
-
* Refresh access token using refresh_token.
|
|
107
|
+
* Refresh access token using refresh_token (Google-specific).
|
|
92
108
|
* @param {string} clientId
|
|
93
109
|
* @param {string} clientSecret
|
|
94
110
|
* @param {string} refreshToken
|
|
@@ -123,12 +139,12 @@ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
|
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
/**
|
|
126
|
-
* Get a valid access token — refreshes automatically if expired.
|
|
142
|
+
* Get a valid Google access token — refreshes automatically if expired.
|
|
127
143
|
* @param {object} config — NHA config with google.clientId etc.
|
|
128
144
|
* @returns {Promise<string>} access_token
|
|
129
145
|
*/
|
|
130
146
|
export async function getAccessToken(config) {
|
|
131
|
-
let tokens = loadTokens();
|
|
147
|
+
let tokens = loadTokens('google');
|
|
132
148
|
if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
|
|
133
149
|
|
|
134
150
|
if (isExpired(tokens)) {
|
|
@@ -137,9 +153,20 @@ export async function getAccessToken(config) {
|
|
|
137
153
|
if (!clientId) throw new Error('Google client ID not configured');
|
|
138
154
|
|
|
139
155
|
tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
|
|
140
|
-
tokens.email = loadTokens()?.email; // preserve email
|
|
141
|
-
saveTokens(tokens);
|
|
156
|
+
tokens.email = loadTokens('google')?.email; // preserve email
|
|
157
|
+
saveTokens(tokens, 'google');
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
return tokens.access_token;
|
|
145
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check which providers are currently authenticated.
|
|
165
|
+
* @returns {{ google: boolean, microsoft: boolean }}
|
|
166
|
+
*/
|
|
167
|
+
export function getAuthenticatedProviders() {
|
|
168
|
+
return {
|
|
169
|
+
google: loadTokens('google') !== null,
|
|
170
|
+
microsoft: loadTokens('microsoft') !== null,
|
|
171
|
+
};
|
|
172
|
+
}
|