nothumanallowed 5.0.0 → 6.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.
@@ -6,6 +6,14 @@
6
6
  *
7
7
  * WebSocket server on port 3848 broadcasts real-time events to connected
8
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.
9
17
  */
10
18
 
11
19
  import fs from 'fs';
@@ -13,17 +21,21 @@ import path from 'path';
13
21
  import http from 'http';
14
22
  import crypto from 'crypto';
15
23
  import { spawn } from 'child_process';
16
- import { NHA_DIR } from '../constants.mjs';
24
+ import { NHA_DIR, DAEMON_SCRIPT } from '../constants.mjs';
17
25
  import { loadConfig } from '../config.mjs';
18
- import { hasMailProvider, getUnreadImportant, getUpcomingEvents } from './mail-router.mjs';
26
+ import { hasMailProvider, getUnreadImportant, getUpcomingEvents, getTodayEmails, listEvents } from './mail-router.mjs';
19
27
  import { notify } from './notification.mjs';
20
28
  import { callAgent } from './llm.mjs';
21
29
  import { runPlanningPipeline } from './ops-pipeline.mjs';
30
+ import { getTasks, getWeekTasks } from './task-store.mjs';
31
+ import { startResponder, stopResponder, getResponderStatus } from './message-responder.mjs';
22
32
 
23
33
  const DAEMON_DIR = path.join(NHA_DIR, 'ops', 'daemon');
24
34
  const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
25
35
  const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
26
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');
27
39
  const WS_PORT = 3848;
28
40
 
29
41
  // ── Daemon Control ─────────────────────────────────────────────────────────
@@ -57,7 +69,7 @@ export function startDaemon() {
57
69
  // The daemon runs this same file with --daemon-loop flag
58
70
  const logFd = fs.openSync(LOG_FILE, 'a');
59
71
  const child = spawn(process.execPath, [
60
- path.join(NHA_DIR, '..', 'packages', 'nha-cli', 'src', 'services', 'ops-daemon.mjs'),
72
+ DAEMON_SCRIPT,
61
73
  '--daemon-loop',
62
74
  ], {
63
75
  detached: true,
@@ -218,6 +230,385 @@ function wsBroadcast(payload) {
218
230
 
219
231
  export { wsBroadcast, WS_PORT };
220
232
 
233
+ // ── Proactive Intelligence Engine ───────────────────────────────────────────
234
+
235
+ /**
236
+ * Notification rate limiter: max 3 proactive notifications per hour.
237
+ * Tracks timestamps of recent proactive notifications.
238
+ */
239
+ const proactiveNotificationTimestamps = [];
240
+ const MAX_PROACTIVE_PER_HOUR = 3;
241
+
242
+ function canSendProactiveNotification() {
243
+ const oneHourAgo = Date.now() - 3_600_000;
244
+ // Purge stale entries
245
+ while (proactiveNotificationTimestamps.length > 0 && proactiveNotificationTimestamps[0] < oneHourAgo) {
246
+ proactiveNotificationTimestamps.shift();
247
+ }
248
+ return proactiveNotificationTimestamps.length < MAX_PROACTIVE_PER_HOUR;
249
+ }
250
+
251
+ function recordProactiveNotification() {
252
+ proactiveNotificationTimestamps.push(Date.now());
253
+ }
254
+
255
+ async function proactiveNotify(title, body, config) {
256
+ if (!canSendProactiveNotification()) {
257
+ log(`[Proactive] Rate limited — skipping: ${title}`);
258
+ return false;
259
+ }
260
+ recordProactiveNotification();
261
+ await notify(title, body, config);
262
+ wsBroadcast({
263
+ type: 'proactive_insight',
264
+ timestamp: new Date().toISOString(),
265
+ data: { title, body },
266
+ });
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * Email Follow-Up Detector
272
+ *
273
+ * Tracks emails that appear to need a reply (questions, requests, action items).
274
+ * After 24 hours with no reply detected, generates a reminder.
275
+ */
276
+ const emailFollowUpTracker = new Map(); // emailId -> { from, subject, receivedAt, reminded }
277
+
278
+ async function checkEmailFollowUps(config) {
279
+ if (!hasMailProvider()) return;
280
+
281
+ const proactiveConfig = config.ops?.proactive || {};
282
+ if (proactiveConfig.emailFollowUp === false) return;
283
+
284
+ try {
285
+ const emails = await getUnreadImportant(config, 20);
286
+ const now = Date.now();
287
+ const twentyFourHours = 24 * 60 * 60 * 1000;
288
+
289
+ // Register new emails that look like they need a reply
290
+ for (const email of emails) {
291
+ if (emailFollowUpTracker.has(email.id)) continue;
292
+
293
+ // Simple heuristic: questions, requests, or emails from known senders
294
+ const subject = (email.subject || '').toLowerCase();
295
+ const snippet = (email.snippet || '').toLowerCase();
296
+ const needsReply = subject.includes('?') ||
297
+ snippet.includes('?') ||
298
+ snippet.includes('please') ||
299
+ snippet.includes('could you') ||
300
+ snippet.includes('can you') ||
301
+ snippet.includes('would you') ||
302
+ snippet.includes('let me know') ||
303
+ snippet.includes('get back to') ||
304
+ snippet.includes('respond') ||
305
+ snippet.includes('reply') ||
306
+ snippet.includes('waiting for') ||
307
+ snippet.includes('action required') ||
308
+ email.isImportant;
309
+
310
+ if (needsReply) {
311
+ emailFollowUpTracker.set(email.id, {
312
+ from: email.from,
313
+ subject: email.subject,
314
+ receivedAt: new Date(email.date || Date.now()).getTime(),
315
+ reminded: false,
316
+ });
317
+ }
318
+ }
319
+
320
+ // Check for overdue follow-ups
321
+ const overdueByFrom = new Map(); // from -> count
322
+ for (const [id, entry] of emailFollowUpTracker) {
323
+ if (entry.reminded) continue;
324
+ if (now - entry.receivedAt < twentyFourHours) continue;
325
+
326
+ entry.reminded = true;
327
+ const fromName = entry.from.split('<')[0].trim() || entry.from;
328
+ const count = (overdueByFrom.get(fromName) || 0) + 1;
329
+ overdueByFrom.set(fromName, count);
330
+ }
331
+
332
+ // Generate grouped notifications
333
+ for (const [fromName, count] of overdueByFrom) {
334
+ const msg = count === 1
335
+ ? `${fromName} sent you an email that may need a reply.`
336
+ : `${fromName} sent you ${count} emails this week that may need replies.`;
337
+
338
+ await proactiveNotify('Email Follow-Up', `${msg} Want me to draft a reply?`, config);
339
+ log(`[Proactive] Email follow-up reminder: ${fromName} (${count} emails)`);
340
+ }
341
+
342
+ // Prune tracker (remove entries older than 7 days)
343
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
344
+ for (const [id, entry] of emailFollowUpTracker) {
345
+ if (now - entry.receivedAt > sevenDays) {
346
+ emailFollowUpTracker.delete(id);
347
+ }
348
+ }
349
+ } catch (err) {
350
+ log(`[Proactive] Email follow-up error: ${err.message}`);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Meeting Prep Auto-Trigger
356
+ *
357
+ * 2 hours before any meeting with >2 attendees, runs HERALD + SCHEHERAZADE
358
+ * to generate a brief. Saves to ~/.nha/ops/briefs/<event-id>.json.
359
+ */
360
+ const preparedMeetings = new Set(); // event IDs already prepped
361
+
362
+ async function checkMeetingPrep(config) {
363
+ if (!hasMailProvider()) return;
364
+
365
+ const proactiveConfig = config.ops?.proactive || {};
366
+ if (proactiveConfig.meetingPrep === false) return;
367
+
368
+ try {
369
+ const upcoming = await getUpcomingEvents(config, 3); // next 3 hours
370
+ const now = Date.now();
371
+
372
+ for (const event of upcoming) {
373
+ const eventStart = new Date(event.start).getTime();
374
+ const minutesUntil = (eventStart - now) / 60_000;
375
+ const eventId = event.id || `${event.summary}-${event.start}`;
376
+
377
+ // Only prep meetings 90-150 min away with >2 attendees
378
+ if (minutesUntil < 90 || minutesUntil > 150) continue;
379
+ if (!event.attendees || event.attendees.length <= 2) continue;
380
+ if (preparedMeetings.has(eventId)) continue;
381
+
382
+ preparedMeetings.add(eventId);
383
+ log(`[Proactive] Generating meeting brief: "${event.summary}" (${Math.round(minutesUntil)}min away)`);
384
+
385
+ try {
386
+ // HERALD: meeting context and logistics brief
387
+ const heraldBrief = await callAgent(config, 'herald',
388
+ `Prepare a comprehensive meeting brief.\n` +
389
+ `Meeting: "${event.summary}"\n` +
390
+ `Start: ${event.start}\n` +
391
+ `Attendees (${event.attendees.length}): ${event.attendees.map(a => a.name || a.email).join(', ')}\n` +
392
+ `Description: ${(event.description || 'No description').slice(0, 1000)}\n` +
393
+ `Location: ${event.location || 'Not specified'}\n\n` +
394
+ `Provide:\n1. Meeting purpose and expected outcomes\n2. Key attendees and their roles\n3. Suggested talking points\n4. Time management recommendations`
395
+ );
396
+
397
+ // SCHEHERAZADE: executive summary and notes template
398
+ const scheherazadeBrief = await callAgent(config, 'scheherazade',
399
+ `Based on this meeting context, create an executive preparation document.\n\n` +
400
+ `Meeting: "${event.summary}"\n` +
401
+ `Attendees: ${event.attendees.map(a => a.name || a.email).join(', ')}\n` +
402
+ `Context from HERALD agent:\n${heraldBrief.slice(0, 2000)}\n\n` +
403
+ `Create:\n1. One-paragraph executive summary\n2. Three key questions to ask\n3. Notes template with sections for decisions, action items, follow-ups`
404
+ );
405
+
406
+ // Save brief
407
+ fs.mkdirSync(BRIEFS_DIR, { recursive: true });
408
+ const briefData = {
409
+ eventId,
410
+ summary: event.summary,
411
+ start: event.start,
412
+ attendees: event.attendees,
413
+ generatedAt: new Date().toISOString(),
414
+ heraldBrief,
415
+ scheherazadeBrief,
416
+ };
417
+
418
+ const safeId = eventId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100);
419
+ fs.writeFileSync(
420
+ path.join(BRIEFS_DIR, `${safeId}.json`),
421
+ JSON.stringify(briefData, null, 2),
422
+ { mode: 0o600 }
423
+ );
424
+
425
+ await proactiveNotify(
426
+ 'Meeting Brief Ready',
427
+ `Brief for "${event.summary}" (${Math.round(minutesUntil)}min away) is ready.\n` +
428
+ `${event.attendees.length} attendees. View at ~/.nha/ops/briefs/`,
429
+ config
430
+ );
431
+
432
+ wsBroadcast({
433
+ type: 'meeting_brief',
434
+ timestamp: new Date().toISOString(),
435
+ data: { eventId, summary: event.summary, minutesUntil: Math.round(minutesUntil) },
436
+ });
437
+
438
+ log(`[Proactive] Meeting brief generated for "${event.summary}"`);
439
+ } catch (err) {
440
+ log(`[Proactive] Meeting prep failed for "${event.summary}": ${err.message}`);
441
+ }
442
+ }
443
+
444
+ // Prune old prepared meeting IDs (keep last 50)
445
+ if (preparedMeetings.size > 50) {
446
+ const arr = [...preparedMeetings];
447
+ for (let i = 0; i < arr.length - 50; i++) {
448
+ preparedMeetings.delete(arr[i]);
449
+ }
450
+ }
451
+ } catch (err) {
452
+ log(`[Proactive] Meeting prep error: ${err.message}`);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Pattern Detection (Weekly Analysis)
458
+ *
459
+ * Runs at summary time. ORACLE analyzes 7 days of tasks, email, and meetings
460
+ * to find patterns and generate actionable insights.
461
+ */
462
+ async function runPatternDetection(config) {
463
+ const proactiveConfig = config.ops?.proactive || {};
464
+ if (proactiveConfig.patterns === false) return;
465
+
466
+ try {
467
+ log('[Proactive] Running weekly pattern detection...');
468
+
469
+ // Gather 7 days of task data
470
+ const weekTasks = getWeekTasks();
471
+ const taskSummary = weekTasks.map(day => {
472
+ const total = day.tasks.length;
473
+ const done = day.tasks.filter(t => t.status === 'done').length;
474
+ const high = day.tasks.filter(t => t.priority === 'high' || t.priority === 'critical').length;
475
+ return `${day.day} ${day.date}: ${total} tasks, ${done} completed, ${high} high-priority`;
476
+ }).join('\n');
477
+
478
+ // Gather email/calendar context if available
479
+ let emailContext = 'Email data: not available';
480
+ let calendarContext = 'Calendar data: not available';
481
+
482
+ if (hasMailProvider()) {
483
+ try {
484
+ const emails = await getTodayEmails(config, 50);
485
+ emailContext = `Email volume today: ${emails.length} emails received`;
486
+ } catch {}
487
+
488
+ try {
489
+ const now = new Date();
490
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
491
+ const events = await listEvents(config, 'primary', weekAgo.toISOString(), now.toISOString());
492
+ const cancelledCount = events.filter(e => e.status === 'cancelled').length;
493
+ calendarContext = `Calendar this week: ${events.length} events total, ${cancelledCount} cancelled`;
494
+ } catch {}
495
+ }
496
+
497
+ // ORACLE analysis
498
+ const analysis = await callAgent(config, 'oracle',
499
+ `Analyze this week's productivity patterns and generate actionable insights.\n\n` +
500
+ `TASK DATA (last 7 days):\n${taskSummary}\n\n` +
501
+ `${emailContext}\n${calendarContext}\n\n` +
502
+ `Generate 2-3 concise, actionable insights based on patterns you detect.\n` +
503
+ `Focus on: task completion trends, workload distribution, meeting load, potential bottlenecks.\n` +
504
+ `Format each insight as: "[PATTERN] observation — [ACTION] suggestion"\n` +
505
+ `Keep each insight to 1-2 sentences maximum.`
506
+ );
507
+
508
+ // Save insight
509
+ fs.mkdirSync(INSIGHTS_DIR, { recursive: true });
510
+ const todayStr = new Date().toISOString().split('T')[0];
511
+ const insightData = {
512
+ date: todayStr,
513
+ generatedAt: new Date().toISOString(),
514
+ analysis,
515
+ taskSummary,
516
+ emailContext,
517
+ calendarContext,
518
+ };
519
+
520
+ fs.writeFileSync(
521
+ path.join(INSIGHTS_DIR, `${todayStr}.json`),
522
+ JSON.stringify(insightData, null, 2),
523
+ { mode: 0o600 }
524
+ );
525
+
526
+ await proactiveNotify('Weekly Insights', analysis.slice(0, 300), config);
527
+
528
+ wsBroadcast({
529
+ type: 'pattern_insight',
530
+ timestamp: new Date().toISOString(),
531
+ data: { date: todayStr, analysis },
532
+ });
533
+
534
+ log(`[Proactive] Pattern detection complete — insight saved to ${todayStr}.json`);
535
+ } catch (err) {
536
+ log(`[Proactive] Pattern detection error: ${err.message}`);
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Deadline Tracker
542
+ *
543
+ * At 9am: alerts about tasks due today.
544
+ * At 5pm: alerts about tasks due tomorrow that haven't been started.
545
+ */
546
+ async function checkDeadlines(config, currentTime) {
547
+ const proactiveConfig = config.ops?.proactive || {};
548
+ if (proactiveConfig.deadlines === false) return;
549
+
550
+ const now = new Date();
551
+
552
+ // 9:00 AM check — tasks due today
553
+ if (currentTime === '09:00') {
554
+ try {
555
+ const todayStr = now.toISOString().split('T')[0];
556
+ const tasks = getTasks(todayStr);
557
+ const dueTodayPending = tasks.filter(t => t.status === 'pending' && t.due);
558
+
559
+ if (dueTodayPending.length > 0) {
560
+ const taskList = dueTodayPending
561
+ .map(t => ` #${t.id}: ${t.description} (${t.priority})`)
562
+ .join('\n');
563
+
564
+ await proactiveNotify(
565
+ 'Tasks Due Today',
566
+ `You have ${dueTodayPending.length} task${dueTodayPending.length > 1 ? 's' : ''} due today:\n${taskList}`,
567
+ config
568
+ );
569
+ log(`[Proactive] Deadline alert: ${dueTodayPending.length} tasks due today`);
570
+ }
571
+ } catch (err) {
572
+ log(`[Proactive] Deadline check (9am) error: ${err.message}`);
573
+ }
574
+ }
575
+
576
+ // 5:00 PM check — tasks due tomorrow that haven't started
577
+ if (currentTime === '17:00') {
578
+ try {
579
+ const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
580
+ const tomorrowStr = tomorrow.toISOString().split('T')[0];
581
+ const tasks = getTasks(tomorrowStr);
582
+ const notStarted = tasks.filter(t => t.status === 'pending');
583
+
584
+ // Also check today's incomplete tasks
585
+ const todayStr = now.toISOString().split('T')[0];
586
+ const todayTasks = getTasks(todayStr);
587
+ const todayIncomplete = todayTasks.filter(t => t.status === 'pending' && (t.priority === 'high' || t.priority === 'critical'));
588
+
589
+ const messages = [];
590
+
591
+ if (notStarted.length > 0) {
592
+ messages.push(`${notStarted.length} task${notStarted.length > 1 ? 's' : ''} due tomorrow not yet started`);
593
+ }
594
+
595
+ if (todayIncomplete.length > 0) {
596
+ const list = todayIncomplete
597
+ .map(t => `#${t.id}: ${t.description}`)
598
+ .join(', ');
599
+ messages.push(`${todayIncomplete.length} high-priority task${todayIncomplete.length > 1 ? 's' : ''} still pending today: ${list}`);
600
+ }
601
+
602
+ if (messages.length > 0) {
603
+ await proactiveNotify('Deadline Reminder', messages.join('\n'), config);
604
+ log(`[Proactive] Deadline alert (5pm): ${messages.join('; ')}`);
605
+ }
606
+ } catch (err) {
607
+ log(`[Proactive] Deadline check (5pm) error: ${err.message}`);
608
+ }
609
+ }
610
+ }
611
+
221
612
  // ── Daemon Loop (runs in background process) ───────────────────────────────
222
613
 
223
614
  function saveState(state) {
@@ -237,11 +628,26 @@ async function daemonLoop() {
237
628
  // Start WebSocket server for real-time push notifications
238
629
  startWebSocketServer();
239
630
 
631
+ // Start message responder if tokens are configured
632
+ const responderResult = startResponder(config, log, wsBroadcast);
633
+ if (responderResult.telegram) log('Message responder: Telegram active');
634
+ if (responderResult.discord) log('Message responder: Discord active');
635
+
240
636
  const state = {
241
637
  startedAt: new Date().toISOString(),
242
638
  lastMailCheck: null,
243
639
  lastCalendarCheck: null,
244
640
  lastPlanGenerated: null,
641
+ lastProactiveCheck: null,
642
+ lastPatternDetection: null,
643
+ responder: responderResult,
644
+ proactive: {
645
+ enabled: config.ops?.proactive?.enabled !== false,
646
+ emailFollowUp: config.ops?.proactive?.emailFollowUp !== false,
647
+ meetingPrep: config.ops?.proactive?.meetingPrep !== false,
648
+ patterns: config.ops?.proactive?.patterns !== false,
649
+ deadlines: config.ops?.proactive?.deadlines !== false,
650
+ },
245
651
  wsPort: WS_PORT,
246
652
  errors: 0,
247
653
  };
@@ -249,21 +655,39 @@ async function daemonLoop() {
249
655
 
250
656
  const MAIL_INTERVAL = config.ops?.pollIntervalMail || 300_000; // 5 min
251
657
  const CAL_INTERVAL = config.ops?.pollIntervalCalendar || 900_000; // 15 min
658
+ const PROACTIVE_INTERVAL = 1_800_000; // 30 min
252
659
  const MEETING_ALERT = config.ops?.meetingAlertMinutes || 30;
253
660
  const PLAN_TIME = config.ops?.planTime || '07:00';
661
+ const SUMMARY_TIME = config.ops?.summaryTime || '18:00';
254
662
 
255
663
  let lastMailCheck = 0;
256
664
  let lastCalCheck = 0;
665
+ let lastProactiveCheck = 0;
257
666
  let todayPlanDone = false;
667
+ let todayPatternDone = false;
258
668
  let knownEmailIds = new Set();
259
669
 
670
+ const proactiveEnabled = config.ops?.proactive?.enabled !== false;
671
+
672
+ // Graceful shutdown — stop responder on SIGTERM
673
+ process.on('SIGTERM', () => {
674
+ log('Received SIGTERM — shutting down');
675
+ stopResponder();
676
+ process.exit(0);
677
+ });
678
+
679
+ process.on('SIGINT', () => {
680
+ log('Received SIGINT — shutting down');
681
+ stopResponder();
682
+ process.exit(0);
683
+ });
684
+
260
685
  // Main loop
261
686
  setInterval(async () => {
262
687
  const now = Date.now();
263
- if (!hasMailProvider()) return; // not authenticated
264
688
 
265
689
  // ── Mail check ─────────────────────────────────────────
266
- if (now - lastMailCheck > MAIL_INTERVAL) {
690
+ if (hasMailProvider() && now - lastMailCheck > MAIL_INTERVAL) {
267
691
  lastMailCheck = now;
268
692
  try {
269
693
  const emails = await getUnreadImportant(config, 10);
@@ -318,7 +742,7 @@ async function daemonLoop() {
318
742
  }
319
743
 
320
744
  // ── Calendar check ─────────────────────────────────────
321
- if (now - lastCalCheck > CAL_INTERVAL) {
745
+ if (hasMailProvider() && now - lastCalCheck > CAL_INTERVAL) {
322
746
  lastCalCheck = now;
323
747
  try {
324
748
  const upcoming = await getUpcomingEvents(config, 1); // next 1 hour
@@ -362,11 +786,38 @@ async function daemonLoop() {
362
786
  }
363
787
  }
364
788
 
365
- // ── Daily plan generation ──────────────────────────────
789
+ // ── Proactive Intelligence Engine ──────────────────────
366
790
  const nowTime = new Date();
367
791
  const currentTime = `${String(nowTime.getHours()).padStart(2, '0')}:${String(nowTime.getMinutes()).padStart(2, '0')}`;
368
792
  const todayStr = nowTime.toISOString().split('T')[0];
369
793
 
794
+ if (proactiveEnabled && now - lastProactiveCheck > PROACTIVE_INTERVAL) {
795
+ lastProactiveCheck = now;
796
+ state.lastProactiveCheck = new Date().toISOString();
797
+ saveState(state);
798
+
799
+ log('[Proactive] Running proactive checks...');
800
+
801
+ // Email follow-up detection
802
+ await checkEmailFollowUps(config);
803
+
804
+ // Meeting prep auto-trigger
805
+ await checkMeetingPrep(config);
806
+
807
+ // Deadline tracking (time-gated inside the function)
808
+ await checkDeadlines(config, currentTime);
809
+ }
810
+
811
+ // ── Pattern detection (daily at summary time) ──────────
812
+ if (proactiveEnabled && !todayPatternDone && currentTime >= SUMMARY_TIME &&
813
+ currentTime < SUMMARY_TIME.replace(/:(\d+)/, (_, m) => ':' + String(Number(m) + 5).padStart(2, '0'))) {
814
+ todayPatternDone = true;
815
+ state.lastPatternDetection = new Date().toISOString();
816
+ saveState(state);
817
+ await runPatternDetection(config);
818
+ }
819
+
820
+ // ── Daily plan generation ──────────────────────────────
370
821
  if (!todayPlanDone && currentTime >= PLAN_TIME && currentTime < PLAN_TIME.replace(/:(\d+)/, (_, m) => ':' + String(Number(m) + 5).padStart(2, '0'))) {
371
822
  todayPlanDone = true;
372
823
  try {
@@ -386,8 +837,11 @@ async function daemonLoop() {
386
837
  }
387
838
  }
388
839
 
389
- // Reset plan flag at midnight
390
- if (currentTime === '00:00') todayPlanDone = false;
840
+ // Reset daily flags at midnight
841
+ if (currentTime === '00:00') {
842
+ todayPlanDone = false;
843
+ todayPatternDone = false;
844
+ }
391
845
 
392
846
  }, 60_000); // Check every 60 seconds
393
847
  }