Skip to content
This repository was archived by the owner on Feb 26, 2026. It is now read-only.

Commit 59f37f3

Browse files
committed
feat(autonomous): work-hours notification batching and smarter delivery pipeline
1 parent e5dbd65 commit 59f37f3

4 files changed

Lines changed: 145 additions & 10 deletions

File tree

‎src/autonomous/agent.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,18 @@ export class AutonomousAgent {
21662166
}
21672167
});
21682168

2169+
// 4 PM weekday work-day digest — flushes all notifications batched during school IT hours
2170+
this.scheduler.registerHandler('workday_digest', async () => {
2171+
try {
2172+
const result = await notificationManager.processQueue();
2173+
if (result.processed > 0) {
2174+
log.info({ processed: result.processed, sent: result.sent }, 'Work-day digest delivered');
2175+
}
2176+
} catch (error) {
2177+
log.error({ error }, 'Work-day digest failed');
2178+
}
2179+
});
2180+
21692181
// ==========================================================================
21702182
// CONTENT ENGINE: Draft generation + delivery to Telegram
21712183
// ==========================================================================

‎src/autonomous/notification-manager.ts‎

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ interface NotificationHistory {
8686

8787
interface NotificationConfig {
8888
quietHours: { start: number; end: number };
89+
workHours: { start: number; end: number }; // School IT hours — batch non-critical
90+
workdayBatchTime: number; // Hour to send work-day queued digest
8991
maxSmsPerHour: number;
9092
maxPerDay: number;
9193
batchTime: number; // Hour to send batched notifications
@@ -103,10 +105,12 @@ interface ChannelConfig {
103105
// ─── Default Configuration ───────────────────────────────────────────────────
104106

105107
const DEFAULT_CONFIG: NotificationConfig = {
106-
quietHours: { start: 22, end: 7 }, // 10pm - 7am Indiana time
108+
quietHours: { start: 22, end: 7 }, // 10pm - 7am Indiana time (sleep)
109+
workHours: { start: 7, end: 16 }, // 7am - 4pm (school IT — non-critical batched)
110+
workdayBatchTime: 16, // 4pm digest of work-day queued items
107111
maxSmsPerHour: 5,
108112
maxPerDay: 20,
109-
batchTime: 7, // 7am - send queued notifications
113+
batchTime: 7, // 7am - send overnight queued notifications
110114
escalationThreshold: 3, // 3 same P1 errors → escalate to P0
111115
timezone: 'America/Indiana/Indianapolis',
112116
cooldowns: {
@@ -116,7 +120,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
116120
milestone: 120, // Nice-to-know, not urgent — was 30
117121
insight: 360, // Max 2-3/day — was 60
118122
question: 0, // ARI needs input — keep at 0
119-
reminder: 0,
123+
reminder: 60, // Max one reminder per hour — was 0 (too noisy)
120124
finance: 240, // 4hr — pre/post-market briefings handle the rest
121125
task: 30, // Batch more — was 15
122126
system: 120, // Background info — was 60
@@ -125,7 +129,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
125129
billing: 1440, // Once per day
126130
value: 720, // Twice per day max
127131
adaptive: 1440, // Once per day
128-
governance: 60, // Once per hour
132+
governance: 120, // Once per 2hr — was 60 (council vote noise)
129133
},
130134
};
131135

@@ -275,24 +279,33 @@ export class NotificationManager {
275279
pLevel: TypedPriority
276280
): Promise<NotificationResult> {
277281
const isQuiet = this.isQuietHours();
282+
const isWork = !isQuiet && this.isWorkHours();
278283
const channels: NotificationResult['channels'] = {};
279284
let sent = false;
280285
let reason = '';
281286

282-
// P0 (critical): SMS + Telegram + Notion — bypasses quiet hours
287+
// P0 (critical): SMS + Telegram + Notion — bypasses everything
283288
if (pLevel === 'P0') {
284289
channels.sms = await this.sendSMS(request, true);
285290
channels.telegram = await this.sendTelegram(request, true);
286291
channels.notion = await this.sendNotion(request, pLevel);
287292
sent = (channels.telegram?.sent ?? false) || (channels.sms?.sent ?? false) || !!channels.notion?.pageId;
288293
reason = 'P0: Immediate delivery (all channels)';
289294
}
290-
// P1 during work hours: Telegram + Notion
295+
// Work hours (7am-4pm M-F): only interrupt-worthy categories fire immediately
296+
// Everything else queues for 4PM workday digest
297+
else if (isWork && !this.isTelegramEligibleDuringWork(request.category)) {
298+
this.queueForWorkday(request, pLevel);
299+
channels.notion = await this.sendNotion(request, pLevel);
300+
sent = false;
301+
reason = `${pLevel}: Deferred to 4 PM work-day digest`;
302+
}
303+
// P1 during active hours: Telegram + Notion
291304
else if (pLevel === 'P1' && !isQuiet) {
292305
channels.telegram = await this.sendTelegram(request, false);
293306
channels.notion = await this.sendNotion(request, pLevel);
294307
sent = (channels.telegram?.sent ?? false) || !!channels.notion?.pageId;
295-
reason = 'P1: Work hours delivery (Telegram + Notion)';
308+
reason = 'P1: Active hours delivery (Telegram + Notion)';
296309
}
297310
// P1 during quiet hours: Queue for morning Telegram
298311
else if (pLevel === 'P1' && isQuiet) {
@@ -301,7 +314,7 @@ export class NotificationManager {
301314
sent = false;
302315
reason = 'P1: Queued for 7 AM Telegram (quiet hours)';
303316
}
304-
// P2 during work hours: Telegram + Notion
317+
// P2 during active hours: Telegram silent + Notion
305318
else if (pLevel === 'P2' && !isQuiet) {
306319
channels.telegram = await this.sendTelegram(request, false, true); // silent push
307320
channels.notion = await this.sendNotion(request, pLevel);
@@ -314,7 +327,7 @@ export class NotificationManager {
314327
sent = false;
315328
reason = `${pLevel}: Queued for morning`;
316329
}
317-
// P3/P4 during work hours: Notion batched
330+
// P3/P4: Notion batched
318331
else {
319332
this.queueForBatch(request, pLevel);
320333
sent = false;
@@ -619,6 +632,76 @@ export class NotificationManager {
619632
return hour >= start && hour < end;
620633
}
621634

635+
/**
636+
* Work hours: 7am-4pm weekdays (school IT — Pryce is at work, can't be interrupted).
637+
* During work hours only security, error, question categories reach Telegram immediately.
638+
* Everything else queues for the 4 PM work-day digest.
639+
*/
640+
private isWorkHours(): boolean {
641+
const now = new Date();
642+
const indiana = new Date(
643+
now.toLocaleString('en-US', { timeZone: this.config.timezone })
644+
);
645+
const hour = indiana.getHours();
646+
const day = indiana.getDay(); // 0=Sun, 6=Sat
647+
648+
if (day === 0 || day === 6) return false; // Weekdays only
649+
const { start, end } = this.config.workHours;
650+
return hour >= start && hour < end;
651+
}
652+
653+
/**
654+
* Categories that can still interrupt Telegram during work hours.
655+
* Anything not in this list queues for 4 PM.
656+
*/
657+
private isTelegramEligibleDuringWork(category: NotificationCategory): boolean {
658+
const eligible: ReadonlySet<NotificationCategory> = new Set([
659+
'security', // Always interrupts — never silence security
660+
'error', // Critical system errors need attention
661+
'question', // ARI is asking for input — needs response
662+
]);
663+
return eligible.has(category);
664+
}
665+
666+
/**
667+
* Queue notification for 4 PM work-day digest delivery.
668+
*/
669+
private queueForWorkday(request: NotificationRequest, pLevel: TypedPriority): void {
670+
const now = new Date();
671+
const indiana = new Date(
672+
now.toLocaleString('en-US', { timeZone: this.config.timezone })
673+
);
674+
675+
const workdayEnd = new Date(indiana);
676+
workdayEnd.setHours(this.config.workdayBatchTime, 0, 0, 0);
677+
678+
// If past 4 PM, queue for tomorrow 4 PM (shouldn't happen — isWorkHours() gates this)
679+
if (indiana.getHours() >= this.config.workdayBatchTime) {
680+
workdayEnd.setDate(workdayEnd.getDate() + 1);
681+
}
682+
683+
const entry: NotificationEntry = {
684+
id: crypto.randomUUID(),
685+
priority: pLevel,
686+
title: request.title,
687+
body: request.body,
688+
category: request.category,
689+
channel: 'both',
690+
queuedAt: now.toISOString(),
691+
queuedFor: workdayEnd.toISOString(),
692+
smsSent: false,
693+
notionSent: false,
694+
dedupKey: request.dedupKey,
695+
escalationCount: 0,
696+
};
697+
698+
this.batchQueue.push({
699+
entry,
700+
scheduledFor: workdayEnd.toISOString(),
701+
reason: 'work_hours_batch',
702+
});
703+
}
704+
622705
/**
623706
* Update Focus Mode state (called by scheduler or agent poll)
624707
* When Focus is active, treat as quiet hours for notification routing.

‎src/autonomous/scheduler.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ const DEFAULT_TASKS: Omit<ScheduledTask, 'lastRun' | 'nextRun'>[] = [
165165
enabled: true,
166166
essential: true, // User-facing deliverable
167167
},
168+
{
169+
id: 'workday-digest',
170+
name: 'Work-Day Digest',
171+
cron: '0 16 * * 1-5', // 4:00 PM weekdays — flush notifications batched during school IT hours
172+
handler: 'workday_digest',
173+
enabled: true,
174+
essential: true, // User-facing deliverable
175+
},
168176

169177
// ============================================================================
170178
// NON-ESSENTIAL TASKS - Skipped when budget is constrained

‎tests/unit/autonomous/notification-manager.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,39 @@ describe('NotificationManager', () => {
204204
});
205205

206206
expect(result.sent).toBe(true);
207-
expect(result.reason).toBe('P1: Work hours delivery (Telegram + Notion)');
207+
expect(result.reason).toBe('P1: Active hours delivery (Telegram + Notion)');
208+
});
209+
210+
it('should defer non-critical notifications during work hours (Mon-Fri 7am-4pm)', async () => {
211+
// 2026-02-02 is a Monday; 14:00 UTC = 9 AM Indiana time (work hours)
212+
vi.setSystemTime(new Date('2026-02-02T14:00:00Z'));
213+
214+
// 'milestone' is not in the telegram-eligible-during-work set → should batch for 4 PM
215+
const result = await manager.notify({
216+
category: 'milestone',
217+
title: 'Milestone',
218+
body: 'Reached goal',
219+
priority: 'normal',
220+
});
221+
222+
expect(result.sent).toBe(false);
223+
expect(result.reason).toBe('P2: Deferred to 4 PM work-day digest');
224+
expect(mockTelegramSend).not.toHaveBeenCalled();
225+
});
226+
227+
it('should let security alerts through even during work hours', async () => {
228+
// 2026-02-02 Monday at 9 AM Indiana
229+
vi.setSystemTime(new Date('2026-02-02T14:00:00Z'));
230+
231+
const result = await manager.notify({
232+
category: 'security',
233+
title: 'Security Alert',
234+
body: 'Intrusion detected',
235+
priority: 'critical',
236+
});
237+
238+
expect(result.sent).toBe(true);
239+
expect(result.reason).toBe('P0: Immediate delivery (all channels)');
208240
});
209241

210242
it('should queue P1 during quiet hours', async () => {

0 commit comments

Comments
 (0)