nothumanallowed 4.1.0 → 6.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 +10 -3
- package/src/cli.mjs +181 -5
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +14 -8
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/ops.mjs +37 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +28 -7
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +61 -0
- package/src/constants.mjs +9 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -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 +620 -11
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/tool-executor.mjs +392 -0
- package/src/services/web-ui.mjs +187 -1
|
@@ -3,24 +3,40 @@
|
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Proactive Intelligence Engine — unsolicited analysis:
|
|
11
|
+
* - Email follow-up detector (24h unreplied)
|
|
12
|
+
* - Meeting prep auto-trigger (2h before large meetings)
|
|
13
|
+
* - Pattern detection (weekly analysis at summary time)
|
|
14
|
+
* - Deadline tracker (9am + 5pm alerts)
|
|
15
|
+
*
|
|
16
|
+
* Message Responder — auto-responds to Telegram/Discord messages via agents.
|
|
6
17
|
*/
|
|
7
18
|
|
|
8
19
|
import fs from 'fs';
|
|
9
20
|
import path from 'path';
|
|
21
|
+
import http from 'http';
|
|
22
|
+
import crypto from 'crypto';
|
|
10
23
|
import { spawn } from 'child_process';
|
|
11
24
|
import { NHA_DIR } from '../constants.mjs';
|
|
12
25
|
import { loadConfig } from '../config.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import { getUnreadImportant } from './google-gmail.mjs';
|
|
15
|
-
import { getUpcomingEvents } from './google-calendar.mjs';
|
|
26
|
+
import { hasMailProvider, getUnreadImportant, getUpcomingEvents, getTodayEmails, listEvents } from './mail-router.mjs';
|
|
16
27
|
import { notify } from './notification.mjs';
|
|
17
28
|
import { callAgent } from './llm.mjs';
|
|
18
29
|
import { runPlanningPipeline } from './ops-pipeline.mjs';
|
|
30
|
+
import { getTasks, getWeekTasks } from './task-store.mjs';
|
|
31
|
+
import { startResponder, stopResponder, getResponderStatus } from './message-responder.mjs';
|
|
19
32
|
|
|
20
33
|
const DAEMON_DIR = path.join(NHA_DIR, 'ops', 'daemon');
|
|
21
34
|
const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
|
|
22
35
|
const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
|
|
23
36
|
const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
|
|
37
|
+
const BRIEFS_DIR = path.join(NHA_DIR, 'ops', 'briefs');
|
|
38
|
+
const INSIGHTS_DIR = path.join(NHA_DIR, 'ops', 'insights');
|
|
39
|
+
const WS_PORT = 3848;
|
|
24
40
|
|
|
25
41
|
// ── Daemon Control ─────────────────────────────────────────────────────────
|
|
26
42
|
|
|
@@ -52,8 +68,9 @@ export function startDaemon() {
|
|
|
52
68
|
|
|
53
69
|
// The daemon runs this same file with --daemon-loop flag
|
|
54
70
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
71
|
+
const { DAEMON_SCRIPT } = await import('../constants.mjs');
|
|
55
72
|
const child = spawn(process.execPath, [
|
|
56
|
-
|
|
73
|
+
DAEMON_SCRIPT,
|
|
57
74
|
'--daemon-loop',
|
|
58
75
|
], {
|
|
59
76
|
detached: true,
|
|
@@ -103,6 +120,496 @@ export function getDaemonStatus() {
|
|
|
103
120
|
return { running, pid, ...state };
|
|
104
121
|
}
|
|
105
122
|
|
|
123
|
+
// ── WebSocket Server (RFC 6455, zero-dependency) ────────────────────────────
|
|
124
|
+
|
|
125
|
+
const wsClients = new Set();
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Minimal WebSocket handshake and frame implementation.
|
|
129
|
+
* Implements just enough of RFC 6455 for text-frame broadcast.
|
|
130
|
+
*/
|
|
131
|
+
function startWebSocketServer() {
|
|
132
|
+
const server = http.createServer((req, res) => {
|
|
133
|
+
if (req.url === '/health') {
|
|
134
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
135
|
+
res.end(JSON.stringify({ ok: true, clients: wsClients.size }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
res.writeHead(404);
|
|
139
|
+
res.end();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.on('upgrade', (req, socket, head) => {
|
|
143
|
+
const key = req.headers['sec-websocket-key'];
|
|
144
|
+
if (!key) { socket.destroy(); return; }
|
|
145
|
+
|
|
146
|
+
const acceptKey = crypto
|
|
147
|
+
.createHash('sha1')
|
|
148
|
+
.update(key + '258EAFA5-E914-47DA-95CA-5AB5DC86C11B')
|
|
149
|
+
.digest('base64');
|
|
150
|
+
|
|
151
|
+
socket.write(
|
|
152
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
153
|
+
'Upgrade: websocket\r\n' +
|
|
154
|
+
'Connection: Upgrade\r\n' +
|
|
155
|
+
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
|
156
|
+
'\r\n'
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
wsClients.add(socket);
|
|
160
|
+
log(`WebSocket client connected (${wsClients.size} total)`);
|
|
161
|
+
|
|
162
|
+
socket.on('data', (buf) => {
|
|
163
|
+
if (buf.length < 2) return;
|
|
164
|
+
const opcode = buf[0] & 0x0f;
|
|
165
|
+
if (opcode === 0x08) { wsClients.delete(socket); socket.end(); return; }
|
|
166
|
+
if (opcode === 0x09) {
|
|
167
|
+
const pong = Buffer.alloc(2);
|
|
168
|
+
pong[0] = 0x8a;
|
|
169
|
+
pong[1] = 0;
|
|
170
|
+
socket.write(pong);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
socket.on('close', () => {
|
|
175
|
+
wsClients.delete(socket);
|
|
176
|
+
log(`WebSocket client disconnected (${wsClients.size} remaining)`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
socket.on('error', () => { wsClients.delete(socket); });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
server.on('error', (err) => {
|
|
183
|
+
if (err.code === 'EADDRINUSE') {
|
|
184
|
+
log(`WebSocket port ${WS_PORT} already in use — skipping WS server`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
log(`WebSocket server error: ${err.message}`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
server.listen(WS_PORT, '127.0.0.1', () => {
|
|
191
|
+
log(`WebSocket server listening on 127.0.0.1:${WS_PORT}`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return server;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Encode a string into a WebSocket text frame (RFC 6455).
|
|
199
|
+
*/
|
|
200
|
+
function encodeWSFrame(text) {
|
|
201
|
+
const data = Buffer.from(text, 'utf-8');
|
|
202
|
+
const len = data.length;
|
|
203
|
+
let header;
|
|
204
|
+
if (len < 126) {
|
|
205
|
+
header = Buffer.alloc(2);
|
|
206
|
+
header[0] = 0x81;
|
|
207
|
+
header[1] = len;
|
|
208
|
+
} else if (len < 65536) {
|
|
209
|
+
header = Buffer.alloc(4);
|
|
210
|
+
header[0] = 0x81;
|
|
211
|
+
header[1] = 126;
|
|
212
|
+
header.writeUInt16BE(len, 2);
|
|
213
|
+
} else {
|
|
214
|
+
header = Buffer.alloc(10);
|
|
215
|
+
header[0] = 0x81;
|
|
216
|
+
header[1] = 127;
|
|
217
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
218
|
+
}
|
|
219
|
+
return Buffer.concat([header, data]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Broadcast a JSON message to all connected WebSocket clients.
|
|
224
|
+
*/
|
|
225
|
+
function wsBroadcast(payload) {
|
|
226
|
+
const frame = encodeWSFrame(JSON.stringify(payload));
|
|
227
|
+
for (const socket of wsClients) {
|
|
228
|
+
try { socket.write(frame); } catch { wsClients.delete(socket); }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { wsBroadcast, WS_PORT };
|
|
233
|
+
|
|
234
|
+
// ── Proactive Intelligence Engine ───────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Notification rate limiter: max 3 proactive notifications per hour.
|
|
238
|
+
* Tracks timestamps of recent proactive notifications.
|
|
239
|
+
*/
|
|
240
|
+
const proactiveNotificationTimestamps = [];
|
|
241
|
+
const MAX_PROACTIVE_PER_HOUR = 3;
|
|
242
|
+
|
|
243
|
+
function canSendProactiveNotification() {
|
|
244
|
+
const oneHourAgo = Date.now() - 3_600_000;
|
|
245
|
+
// Purge stale entries
|
|
246
|
+
while (proactiveNotificationTimestamps.length > 0 && proactiveNotificationTimestamps[0] < oneHourAgo) {
|
|
247
|
+
proactiveNotificationTimestamps.shift();
|
|
248
|
+
}
|
|
249
|
+
return proactiveNotificationTimestamps.length < MAX_PROACTIVE_PER_HOUR;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function recordProactiveNotification() {
|
|
253
|
+
proactiveNotificationTimestamps.push(Date.now());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function proactiveNotify(title, body, config) {
|
|
257
|
+
if (!canSendProactiveNotification()) {
|
|
258
|
+
log(`[Proactive] Rate limited — skipping: ${title}`);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
recordProactiveNotification();
|
|
262
|
+
await notify(title, body, config);
|
|
263
|
+
wsBroadcast({
|
|
264
|
+
type: 'proactive_insight',
|
|
265
|
+
timestamp: new Date().toISOString(),
|
|
266
|
+
data: { title, body },
|
|
267
|
+
});
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Email Follow-Up Detector
|
|
273
|
+
*
|
|
274
|
+
* Tracks emails that appear to need a reply (questions, requests, action items).
|
|
275
|
+
* After 24 hours with no reply detected, generates a reminder.
|
|
276
|
+
*/
|
|
277
|
+
const emailFollowUpTracker = new Map(); // emailId -> { from, subject, receivedAt, reminded }
|
|
278
|
+
|
|
279
|
+
async function checkEmailFollowUps(config) {
|
|
280
|
+
if (!hasMailProvider()) return;
|
|
281
|
+
|
|
282
|
+
const proactiveConfig = config.ops?.proactive || {};
|
|
283
|
+
if (proactiveConfig.emailFollowUp === false) return;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const emails = await getUnreadImportant(config, 20);
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const twentyFourHours = 24 * 60 * 60 * 1000;
|
|
289
|
+
|
|
290
|
+
// Register new emails that look like they need a reply
|
|
291
|
+
for (const email of emails) {
|
|
292
|
+
if (emailFollowUpTracker.has(email.id)) continue;
|
|
293
|
+
|
|
294
|
+
// Simple heuristic: questions, requests, or emails from known senders
|
|
295
|
+
const subject = (email.subject || '').toLowerCase();
|
|
296
|
+
const snippet = (email.snippet || '').toLowerCase();
|
|
297
|
+
const needsReply = subject.includes('?') ||
|
|
298
|
+
snippet.includes('?') ||
|
|
299
|
+
snippet.includes('please') ||
|
|
300
|
+
snippet.includes('could you') ||
|
|
301
|
+
snippet.includes('can you') ||
|
|
302
|
+
snippet.includes('would you') ||
|
|
303
|
+
snippet.includes('let me know') ||
|
|
304
|
+
snippet.includes('get back to') ||
|
|
305
|
+
snippet.includes('respond') ||
|
|
306
|
+
snippet.includes('reply') ||
|
|
307
|
+
snippet.includes('waiting for') ||
|
|
308
|
+
snippet.includes('action required') ||
|
|
309
|
+
email.isImportant;
|
|
310
|
+
|
|
311
|
+
if (needsReply) {
|
|
312
|
+
emailFollowUpTracker.set(email.id, {
|
|
313
|
+
from: email.from,
|
|
314
|
+
subject: email.subject,
|
|
315
|
+
receivedAt: new Date(email.date || Date.now()).getTime(),
|
|
316
|
+
reminded: false,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check for overdue follow-ups
|
|
322
|
+
const overdueByFrom = new Map(); // from -> count
|
|
323
|
+
for (const [id, entry] of emailFollowUpTracker) {
|
|
324
|
+
if (entry.reminded) continue;
|
|
325
|
+
if (now - entry.receivedAt < twentyFourHours) continue;
|
|
326
|
+
|
|
327
|
+
entry.reminded = true;
|
|
328
|
+
const fromName = entry.from.split('<')[0].trim() || entry.from;
|
|
329
|
+
const count = (overdueByFrom.get(fromName) || 0) + 1;
|
|
330
|
+
overdueByFrom.set(fromName, count);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Generate grouped notifications
|
|
334
|
+
for (const [fromName, count] of overdueByFrom) {
|
|
335
|
+
const msg = count === 1
|
|
336
|
+
? `${fromName} sent you an email that may need a reply.`
|
|
337
|
+
: `${fromName} sent you ${count} emails this week that may need replies.`;
|
|
338
|
+
|
|
339
|
+
await proactiveNotify('Email Follow-Up', `${msg} Want me to draft a reply?`, config);
|
|
340
|
+
log(`[Proactive] Email follow-up reminder: ${fromName} (${count} emails)`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Prune tracker (remove entries older than 7 days)
|
|
344
|
+
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
|
345
|
+
for (const [id, entry] of emailFollowUpTracker) {
|
|
346
|
+
if (now - entry.receivedAt > sevenDays) {
|
|
347
|
+
emailFollowUpTracker.delete(id);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
log(`[Proactive] Email follow-up error: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Meeting Prep Auto-Trigger
|
|
357
|
+
*
|
|
358
|
+
* 2 hours before any meeting with >2 attendees, runs HERALD + SCHEHERAZADE
|
|
359
|
+
* to generate a brief. Saves to ~/.nha/ops/briefs/<event-id>.json.
|
|
360
|
+
*/
|
|
361
|
+
const preparedMeetings = new Set(); // event IDs already prepped
|
|
362
|
+
|
|
363
|
+
async function checkMeetingPrep(config) {
|
|
364
|
+
if (!hasMailProvider()) return;
|
|
365
|
+
|
|
366
|
+
const proactiveConfig = config.ops?.proactive || {};
|
|
367
|
+
if (proactiveConfig.meetingPrep === false) return;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const upcoming = await getUpcomingEvents(config, 3); // next 3 hours
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
|
|
373
|
+
for (const event of upcoming) {
|
|
374
|
+
const eventStart = new Date(event.start).getTime();
|
|
375
|
+
const minutesUntil = (eventStart - now) / 60_000;
|
|
376
|
+
const eventId = event.id || `${event.summary}-${event.start}`;
|
|
377
|
+
|
|
378
|
+
// Only prep meetings 90-150 min away with >2 attendees
|
|
379
|
+
if (minutesUntil < 90 || minutesUntil > 150) continue;
|
|
380
|
+
if (!event.attendees || event.attendees.length <= 2) continue;
|
|
381
|
+
if (preparedMeetings.has(eventId)) continue;
|
|
382
|
+
|
|
383
|
+
preparedMeetings.add(eventId);
|
|
384
|
+
log(`[Proactive] Generating meeting brief: "${event.summary}" (${Math.round(minutesUntil)}min away)`);
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
// HERALD: meeting context and logistics brief
|
|
388
|
+
const heraldBrief = await callAgent(config, 'herald',
|
|
389
|
+
`Prepare a comprehensive meeting brief.\n` +
|
|
390
|
+
`Meeting: "${event.summary}"\n` +
|
|
391
|
+
`Start: ${event.start}\n` +
|
|
392
|
+
`Attendees (${event.attendees.length}): ${event.attendees.map(a => a.name || a.email).join(', ')}\n` +
|
|
393
|
+
`Description: ${(event.description || 'No description').slice(0, 1000)}\n` +
|
|
394
|
+
`Location: ${event.location || 'Not specified'}\n\n` +
|
|
395
|
+
`Provide:\n1. Meeting purpose and expected outcomes\n2. Key attendees and their roles\n3. Suggested talking points\n4. Time management recommendations`
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// SCHEHERAZADE: executive summary and notes template
|
|
399
|
+
const scheherazadeBrief = await callAgent(config, 'scheherazade',
|
|
400
|
+
`Based on this meeting context, create an executive preparation document.\n\n` +
|
|
401
|
+
`Meeting: "${event.summary}"\n` +
|
|
402
|
+
`Attendees: ${event.attendees.map(a => a.name || a.email).join(', ')}\n` +
|
|
403
|
+
`Context from HERALD agent:\n${heraldBrief.slice(0, 2000)}\n\n` +
|
|
404
|
+
`Create:\n1. One-paragraph executive summary\n2. Three key questions to ask\n3. Notes template with sections for decisions, action items, follow-ups`
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Save brief
|
|
408
|
+
fs.mkdirSync(BRIEFS_DIR, { recursive: true });
|
|
409
|
+
const briefData = {
|
|
410
|
+
eventId,
|
|
411
|
+
summary: event.summary,
|
|
412
|
+
start: event.start,
|
|
413
|
+
attendees: event.attendees,
|
|
414
|
+
generatedAt: new Date().toISOString(),
|
|
415
|
+
heraldBrief,
|
|
416
|
+
scheherazadeBrief,
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const safeId = eventId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100);
|
|
420
|
+
fs.writeFileSync(
|
|
421
|
+
path.join(BRIEFS_DIR, `${safeId}.json`),
|
|
422
|
+
JSON.stringify(briefData, null, 2),
|
|
423
|
+
{ mode: 0o600 }
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
await proactiveNotify(
|
|
427
|
+
'Meeting Brief Ready',
|
|
428
|
+
`Brief for "${event.summary}" (${Math.round(minutesUntil)}min away) is ready.\n` +
|
|
429
|
+
`${event.attendees.length} attendees. View at ~/.nha/ops/briefs/`,
|
|
430
|
+
config
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
wsBroadcast({
|
|
434
|
+
type: 'meeting_brief',
|
|
435
|
+
timestamp: new Date().toISOString(),
|
|
436
|
+
data: { eventId, summary: event.summary, minutesUntil: Math.round(minutesUntil) },
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
log(`[Proactive] Meeting brief generated for "${event.summary}"`);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
log(`[Proactive] Meeting prep failed for "${event.summary}": ${err.message}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Prune old prepared meeting IDs (keep last 50)
|
|
446
|
+
if (preparedMeetings.size > 50) {
|
|
447
|
+
const arr = [...preparedMeetings];
|
|
448
|
+
for (let i = 0; i < arr.length - 50; i++) {
|
|
449
|
+
preparedMeetings.delete(arr[i]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} catch (err) {
|
|
453
|
+
log(`[Proactive] Meeting prep error: ${err.message}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Pattern Detection (Weekly Analysis)
|
|
459
|
+
*
|
|
460
|
+
* Runs at summary time. ORACLE analyzes 7 days of tasks, email, and meetings
|
|
461
|
+
* to find patterns and generate actionable insights.
|
|
462
|
+
*/
|
|
463
|
+
async function runPatternDetection(config) {
|
|
464
|
+
const proactiveConfig = config.ops?.proactive || {};
|
|
465
|
+
if (proactiveConfig.patterns === false) return;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
log('[Proactive] Running weekly pattern detection...');
|
|
469
|
+
|
|
470
|
+
// Gather 7 days of task data
|
|
471
|
+
const weekTasks = getWeekTasks();
|
|
472
|
+
const taskSummary = weekTasks.map(day => {
|
|
473
|
+
const total = day.tasks.length;
|
|
474
|
+
const done = day.tasks.filter(t => t.status === 'done').length;
|
|
475
|
+
const high = day.tasks.filter(t => t.priority === 'high' || t.priority === 'critical').length;
|
|
476
|
+
return `${day.day} ${day.date}: ${total} tasks, ${done} completed, ${high} high-priority`;
|
|
477
|
+
}).join('\n');
|
|
478
|
+
|
|
479
|
+
// Gather email/calendar context if available
|
|
480
|
+
let emailContext = 'Email data: not available';
|
|
481
|
+
let calendarContext = 'Calendar data: not available';
|
|
482
|
+
|
|
483
|
+
if (hasMailProvider()) {
|
|
484
|
+
try {
|
|
485
|
+
const emails = await getTodayEmails(config, 50);
|
|
486
|
+
emailContext = `Email volume today: ${emails.length} emails received`;
|
|
487
|
+
} catch {}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const now = new Date();
|
|
491
|
+
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
492
|
+
const events = await listEvents(config, 'primary', weekAgo.toISOString(), now.toISOString());
|
|
493
|
+
const cancelledCount = events.filter(e => e.status === 'cancelled').length;
|
|
494
|
+
calendarContext = `Calendar this week: ${events.length} events total, ${cancelledCount} cancelled`;
|
|
495
|
+
} catch {}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ORACLE analysis
|
|
499
|
+
const analysis = await callAgent(config, 'oracle',
|
|
500
|
+
`Analyze this week's productivity patterns and generate actionable insights.\n\n` +
|
|
501
|
+
`TASK DATA (last 7 days):\n${taskSummary}\n\n` +
|
|
502
|
+
`${emailContext}\n${calendarContext}\n\n` +
|
|
503
|
+
`Generate 2-3 concise, actionable insights based on patterns you detect.\n` +
|
|
504
|
+
`Focus on: task completion trends, workload distribution, meeting load, potential bottlenecks.\n` +
|
|
505
|
+
`Format each insight as: "[PATTERN] observation — [ACTION] suggestion"\n` +
|
|
506
|
+
`Keep each insight to 1-2 sentences maximum.`
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Save insight
|
|
510
|
+
fs.mkdirSync(INSIGHTS_DIR, { recursive: true });
|
|
511
|
+
const todayStr = new Date().toISOString().split('T')[0];
|
|
512
|
+
const insightData = {
|
|
513
|
+
date: todayStr,
|
|
514
|
+
generatedAt: new Date().toISOString(),
|
|
515
|
+
analysis,
|
|
516
|
+
taskSummary,
|
|
517
|
+
emailContext,
|
|
518
|
+
calendarContext,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
fs.writeFileSync(
|
|
522
|
+
path.join(INSIGHTS_DIR, `${todayStr}.json`),
|
|
523
|
+
JSON.stringify(insightData, null, 2),
|
|
524
|
+
{ mode: 0o600 }
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
await proactiveNotify('Weekly Insights', analysis.slice(0, 300), config);
|
|
528
|
+
|
|
529
|
+
wsBroadcast({
|
|
530
|
+
type: 'pattern_insight',
|
|
531
|
+
timestamp: new Date().toISOString(),
|
|
532
|
+
data: { date: todayStr, analysis },
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
log(`[Proactive] Pattern detection complete — insight saved to ${todayStr}.json`);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
log(`[Proactive] Pattern detection error: ${err.message}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Deadline Tracker
|
|
543
|
+
*
|
|
544
|
+
* At 9am: alerts about tasks due today.
|
|
545
|
+
* At 5pm: alerts about tasks due tomorrow that haven't been started.
|
|
546
|
+
*/
|
|
547
|
+
async function checkDeadlines(config, currentTime) {
|
|
548
|
+
const proactiveConfig = config.ops?.proactive || {};
|
|
549
|
+
if (proactiveConfig.deadlines === false) return;
|
|
550
|
+
|
|
551
|
+
const now = new Date();
|
|
552
|
+
|
|
553
|
+
// 9:00 AM check — tasks due today
|
|
554
|
+
if (currentTime === '09:00') {
|
|
555
|
+
try {
|
|
556
|
+
const todayStr = now.toISOString().split('T')[0];
|
|
557
|
+
const tasks = getTasks(todayStr);
|
|
558
|
+
const dueTodayPending = tasks.filter(t => t.status === 'pending' && t.due);
|
|
559
|
+
|
|
560
|
+
if (dueTodayPending.length > 0) {
|
|
561
|
+
const taskList = dueTodayPending
|
|
562
|
+
.map(t => ` #${t.id}: ${t.description} (${t.priority})`)
|
|
563
|
+
.join('\n');
|
|
564
|
+
|
|
565
|
+
await proactiveNotify(
|
|
566
|
+
'Tasks Due Today',
|
|
567
|
+
`You have ${dueTodayPending.length} task${dueTodayPending.length > 1 ? 's' : ''} due today:\n${taskList}`,
|
|
568
|
+
config
|
|
569
|
+
);
|
|
570
|
+
log(`[Proactive] Deadline alert: ${dueTodayPending.length} tasks due today`);
|
|
571
|
+
}
|
|
572
|
+
} catch (err) {
|
|
573
|
+
log(`[Proactive] Deadline check (9am) error: ${err.message}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 5:00 PM check — tasks due tomorrow that haven't started
|
|
578
|
+
if (currentTime === '17:00') {
|
|
579
|
+
try {
|
|
580
|
+
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
581
|
+
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
|
582
|
+
const tasks = getTasks(tomorrowStr);
|
|
583
|
+
const notStarted = tasks.filter(t => t.status === 'pending');
|
|
584
|
+
|
|
585
|
+
// Also check today's incomplete tasks
|
|
586
|
+
const todayStr = now.toISOString().split('T')[0];
|
|
587
|
+
const todayTasks = getTasks(todayStr);
|
|
588
|
+
const todayIncomplete = todayTasks.filter(t => t.status === 'pending' && (t.priority === 'high' || t.priority === 'critical'));
|
|
589
|
+
|
|
590
|
+
const messages = [];
|
|
591
|
+
|
|
592
|
+
if (notStarted.length > 0) {
|
|
593
|
+
messages.push(`${notStarted.length} task${notStarted.length > 1 ? 's' : ''} due tomorrow not yet started`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (todayIncomplete.length > 0) {
|
|
597
|
+
const list = todayIncomplete
|
|
598
|
+
.map(t => `#${t.id}: ${t.description}`)
|
|
599
|
+
.join(', ');
|
|
600
|
+
messages.push(`${todayIncomplete.length} high-priority task${todayIncomplete.length > 1 ? 's' : ''} still pending today: ${list}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (messages.length > 0) {
|
|
604
|
+
await proactiveNotify('Deadline Reminder', messages.join('\n'), config);
|
|
605
|
+
log(`[Proactive] Deadline alert (5pm): ${messages.join('; ')}`);
|
|
606
|
+
}
|
|
607
|
+
} catch (err) {
|
|
608
|
+
log(`[Proactive] Deadline check (5pm) error: ${err.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
106
613
|
// ── Daemon Loop (runs in background process) ───────────────────────────────
|
|
107
614
|
|
|
108
615
|
function saveState(state) {
|
|
@@ -119,33 +626,69 @@ async function daemonLoop() {
|
|
|
119
626
|
log('NHA PAO Daemon started');
|
|
120
627
|
const config = loadConfig();
|
|
121
628
|
|
|
629
|
+
// Start WebSocket server for real-time push notifications
|
|
630
|
+
startWebSocketServer();
|
|
631
|
+
|
|
632
|
+
// Start message responder if tokens are configured
|
|
633
|
+
const responderResult = startResponder(config, log, wsBroadcast);
|
|
634
|
+
if (responderResult.telegram) log('Message responder: Telegram active');
|
|
635
|
+
if (responderResult.discord) log('Message responder: Discord active');
|
|
636
|
+
|
|
122
637
|
const state = {
|
|
123
638
|
startedAt: new Date().toISOString(),
|
|
124
639
|
lastMailCheck: null,
|
|
125
640
|
lastCalendarCheck: null,
|
|
126
641
|
lastPlanGenerated: null,
|
|
642
|
+
lastProactiveCheck: null,
|
|
643
|
+
lastPatternDetection: null,
|
|
644
|
+
responder: responderResult,
|
|
645
|
+
proactive: {
|
|
646
|
+
enabled: config.ops?.proactive?.enabled !== false,
|
|
647
|
+
emailFollowUp: config.ops?.proactive?.emailFollowUp !== false,
|
|
648
|
+
meetingPrep: config.ops?.proactive?.meetingPrep !== false,
|
|
649
|
+
patterns: config.ops?.proactive?.patterns !== false,
|
|
650
|
+
deadlines: config.ops?.proactive?.deadlines !== false,
|
|
651
|
+
},
|
|
652
|
+
wsPort: WS_PORT,
|
|
127
653
|
errors: 0,
|
|
128
654
|
};
|
|
129
655
|
saveState(state);
|
|
130
656
|
|
|
131
657
|
const MAIL_INTERVAL = config.ops?.pollIntervalMail || 300_000; // 5 min
|
|
132
658
|
const CAL_INTERVAL = config.ops?.pollIntervalCalendar || 900_000; // 15 min
|
|
659
|
+
const PROACTIVE_INTERVAL = 1_800_000; // 30 min
|
|
133
660
|
const MEETING_ALERT = config.ops?.meetingAlertMinutes || 30;
|
|
134
661
|
const PLAN_TIME = config.ops?.planTime || '07:00';
|
|
662
|
+
const SUMMARY_TIME = config.ops?.summaryTime || '18:00';
|
|
135
663
|
|
|
136
664
|
let lastMailCheck = 0;
|
|
137
665
|
let lastCalCheck = 0;
|
|
666
|
+
let lastProactiveCheck = 0;
|
|
138
667
|
let todayPlanDone = false;
|
|
668
|
+
let todayPatternDone = false;
|
|
139
669
|
let knownEmailIds = new Set();
|
|
140
670
|
|
|
671
|
+
const proactiveEnabled = config.ops?.proactive?.enabled !== false;
|
|
672
|
+
|
|
673
|
+
// Graceful shutdown — stop responder on SIGTERM
|
|
674
|
+
process.on('SIGTERM', () => {
|
|
675
|
+
log('Received SIGTERM — shutting down');
|
|
676
|
+
stopResponder();
|
|
677
|
+
process.exit(0);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
process.on('SIGINT', () => {
|
|
681
|
+
log('Received SIGINT — shutting down');
|
|
682
|
+
stopResponder();
|
|
683
|
+
process.exit(0);
|
|
684
|
+
});
|
|
685
|
+
|
|
141
686
|
// Main loop
|
|
142
687
|
setInterval(async () => {
|
|
143
688
|
const now = Date.now();
|
|
144
|
-
const tokens = loadTokens();
|
|
145
|
-
if (!tokens) return; // not authenticated
|
|
146
689
|
|
|
147
690
|
// ── Mail check ─────────────────────────────────────────
|
|
148
|
-
if (now - lastMailCheck > MAIL_INTERVAL) {
|
|
691
|
+
if (hasMailProvider() && now - lastMailCheck > MAIL_INTERVAL) {
|
|
149
692
|
lastMailCheck = now;
|
|
150
693
|
try {
|
|
151
694
|
const emails = await getUnreadImportant(config, 10);
|
|
@@ -154,6 +697,19 @@ async function daemonLoop() {
|
|
|
154
697
|
for (const email of newEmails) {
|
|
155
698
|
knownEmailIds.add(email.id);
|
|
156
699
|
|
|
700
|
+
// Broadcast new email event to WebSocket clients
|
|
701
|
+
wsBroadcast({
|
|
702
|
+
type: 'new_email',
|
|
703
|
+
timestamp: new Date().toISOString(),
|
|
704
|
+
data: {
|
|
705
|
+
id: email.id,
|
|
706
|
+
from: email.from,
|
|
707
|
+
subject: email.subject,
|
|
708
|
+
snippet: (email.snippet || '').slice(0, 120),
|
|
709
|
+
isImportant: email.isImportant,
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
|
|
157
713
|
// SABER quick scan for suspicious emails
|
|
158
714
|
if (email.urls.length > 0 || email.from.includes('paypal') || email.from.includes('bank') || email.subject.toLowerCase().includes('urgent')) {
|
|
159
715
|
try {
|
|
@@ -162,6 +718,11 @@ async function daemonLoop() {
|
|
|
162
718
|
);
|
|
163
719
|
if (scanResult.toUpperCase().includes('FLAGGED')) {
|
|
164
720
|
await notify('Security Alert', `Suspicious email from ${email.from}: ${email.subject}\n${scanResult}`, config);
|
|
721
|
+
wsBroadcast({
|
|
722
|
+
type: 'security_alert',
|
|
723
|
+
timestamp: new Date().toISOString(),
|
|
724
|
+
data: { from: email.from, subject: email.subject, reason: scanResult },
|
|
725
|
+
});
|
|
165
726
|
}
|
|
166
727
|
} catch { /* non-fatal */ }
|
|
167
728
|
}
|
|
@@ -182,7 +743,7 @@ async function daemonLoop() {
|
|
|
182
743
|
}
|
|
183
744
|
|
|
184
745
|
// ── Calendar check ─────────────────────────────────────
|
|
185
|
-
if (now - lastCalCheck > CAL_INTERVAL) {
|
|
746
|
+
if (hasMailProvider() && now - lastCalCheck > CAL_INTERVAL) {
|
|
186
747
|
lastCalCheck = now;
|
|
187
748
|
try {
|
|
188
749
|
const upcoming = await getUpcomingEvents(config, 1); // next 1 hour
|
|
@@ -192,6 +753,19 @@ async function daemonLoop() {
|
|
|
192
753
|
const minutesUntil = (eventStart - now) / 60000;
|
|
193
754
|
|
|
194
755
|
if (minutesUntil > 0 && minutesUntil <= MEETING_ALERT) {
|
|
756
|
+
// Broadcast meeting alert to WebSocket clients
|
|
757
|
+
wsBroadcast({
|
|
758
|
+
type: 'meeting_alert',
|
|
759
|
+
timestamp: new Date().toISOString(),
|
|
760
|
+
data: {
|
|
761
|
+
summary: event.summary,
|
|
762
|
+
start: event.start,
|
|
763
|
+
minutesUntil: Math.round(minutesUntil),
|
|
764
|
+
location: event.location || null,
|
|
765
|
+
hangoutLink: event.hangoutLink || null,
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
|
|
195
769
|
// Generate quick brief with HERALD
|
|
196
770
|
try {
|
|
197
771
|
const brief = await callAgent(config, 'herald',
|
|
@@ -213,11 +787,38 @@ async function daemonLoop() {
|
|
|
213
787
|
}
|
|
214
788
|
}
|
|
215
789
|
|
|
216
|
-
// ──
|
|
790
|
+
// ── Proactive Intelligence Engine ──────────────────────
|
|
217
791
|
const nowTime = new Date();
|
|
218
792
|
const currentTime = `${String(nowTime.getHours()).padStart(2, '0')}:${String(nowTime.getMinutes()).padStart(2, '0')}`;
|
|
219
793
|
const todayStr = nowTime.toISOString().split('T')[0];
|
|
220
794
|
|
|
795
|
+
if (proactiveEnabled && now - lastProactiveCheck > PROACTIVE_INTERVAL) {
|
|
796
|
+
lastProactiveCheck = now;
|
|
797
|
+
state.lastProactiveCheck = new Date().toISOString();
|
|
798
|
+
saveState(state);
|
|
799
|
+
|
|
800
|
+
log('[Proactive] Running proactive checks...');
|
|
801
|
+
|
|
802
|
+
// Email follow-up detection
|
|
803
|
+
await checkEmailFollowUps(config);
|
|
804
|
+
|
|
805
|
+
// Meeting prep auto-trigger
|
|
806
|
+
await checkMeetingPrep(config);
|
|
807
|
+
|
|
808
|
+
// Deadline tracking (time-gated inside the function)
|
|
809
|
+
await checkDeadlines(config, currentTime);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── Pattern detection (daily at summary time) ──────────
|
|
813
|
+
if (proactiveEnabled && !todayPatternDone && currentTime >= SUMMARY_TIME &&
|
|
814
|
+
currentTime < SUMMARY_TIME.replace(/:(\d+)/, (_, m) => ':' + String(Number(m) + 5).padStart(2, '0'))) {
|
|
815
|
+
todayPatternDone = true;
|
|
816
|
+
state.lastPatternDetection = new Date().toISOString();
|
|
817
|
+
saveState(state);
|
|
818
|
+
await runPatternDetection(config);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── Daily plan generation ──────────────────────────────
|
|
221
822
|
if (!todayPlanDone && currentTime >= PLAN_TIME && currentTime < PLAN_TIME.replace(/:(\d+)/, (_, m) => ':' + String(Number(m) + 5).padStart(2, '0'))) {
|
|
222
823
|
todayPlanDone = true;
|
|
223
824
|
try {
|
|
@@ -226,14 +827,22 @@ async function daemonLoop() {
|
|
|
226
827
|
state.lastPlanGenerated = new Date().toISOString();
|
|
227
828
|
saveState(state);
|
|
228
829
|
await notify('Daily Plan Ready', `Your plan for ${todayStr} is ready. Run "nha plan --show" to view.`, config);
|
|
830
|
+
wsBroadcast({
|
|
831
|
+
type: 'plan_ready',
|
|
832
|
+
timestamp: new Date().toISOString(),
|
|
833
|
+
data: { date: todayStr },
|
|
834
|
+
});
|
|
229
835
|
log('Daily plan generated');
|
|
230
836
|
} catch (err) {
|
|
231
837
|
log(`Plan generation error: ${err.message}`);
|
|
232
838
|
}
|
|
233
839
|
}
|
|
234
840
|
|
|
235
|
-
// Reset
|
|
236
|
-
if (currentTime === '00:00')
|
|
841
|
+
// Reset daily flags at midnight
|
|
842
|
+
if (currentTime === '00:00') {
|
|
843
|
+
todayPlanDone = false;
|
|
844
|
+
todayPatternDone = false;
|
|
845
|
+
}
|
|
237
846
|
|
|
238
847
|
}, 60_000); // Check every 60 seconds
|
|
239
848
|
}
|