@auxiora/ambient 1.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.
Files changed (48) hide show
  1. package/LICENSE +191 -0
  2. package/dist/anticipation.d.ts +27 -0
  3. package/dist/anticipation.d.ts.map +1 -0
  4. package/dist/anticipation.js +128 -0
  5. package/dist/anticipation.js.map +1 -0
  6. package/dist/briefing.d.ts +43 -0
  7. package/dist/briefing.d.ts.map +1 -0
  8. package/dist/briefing.js +109 -0
  9. package/dist/briefing.js.map +1 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +8 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/notification.d.ts +29 -0
  15. package/dist/notification.d.ts.map +1 -0
  16. package/dist/notification.js +87 -0
  17. package/dist/notification.js.map +1 -0
  18. package/dist/orchestrator.d.ts +40 -0
  19. package/dist/orchestrator.d.ts.map +1 -0
  20. package/dist/orchestrator.js +130 -0
  21. package/dist/orchestrator.js.map +1 -0
  22. package/dist/pattern-engine.d.ts +27 -0
  23. package/dist/pattern-engine.d.ts.map +1 -0
  24. package/dist/pattern-engine.js +187 -0
  25. package/dist/pattern-engine.js.map +1 -0
  26. package/dist/scheduler.d.ts +88 -0
  27. package/dist/scheduler.d.ts.map +1 -0
  28. package/dist/scheduler.js +172 -0
  29. package/dist/scheduler.js.map +1 -0
  30. package/dist/types.d.ts +73 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +8 -0
  33. package/dist/types.js.map +1 -0
  34. package/package.json +29 -0
  35. package/src/anticipation.ts +141 -0
  36. package/src/briefing.ts +152 -0
  37. package/src/index.ts +26 -0
  38. package/src/notification.ts +101 -0
  39. package/src/orchestrator.ts +188 -0
  40. package/src/pattern-engine.ts +212 -0
  41. package/src/scheduler.ts +238 -0
  42. package/src/types.ts +85 -0
  43. package/tests/ambient.test.ts +363 -0
  44. package/tests/orchestrator.test.ts +343 -0
  45. package/tests/scheduler.test.ts +310 -0
  46. package/tests/wiring.test.ts +12 -0
  47. package/tsconfig.json +15 -0
  48. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,343 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { NotificationOrchestrator } from '../src/orchestrator.js';
3
+ import type { OrchestratorNotification, DeliveryChannelFn } from '../src/orchestrator.js';
4
+ import type { TriggerEvent } from '@auxiora/connectors';
5
+ import { NotificationHub, DoNotDisturbManager } from '@auxiora/notification-hub';
6
+
7
+ describe('NotificationOrchestrator', () => {
8
+ let hub: NotificationHub;
9
+ let dnd: DoNotDisturbManager;
10
+ let delivered: OrchestratorNotification[];
11
+ let deliveryChannel: DeliveryChannelFn;
12
+ let orchestrator: NotificationOrchestrator;
13
+
14
+ beforeEach(() => {
15
+ hub = new NotificationHub();
16
+ dnd = new DoNotDisturbManager();
17
+ delivered = [];
18
+ deliveryChannel = (n) => delivered.push(n);
19
+ orchestrator = new NotificationOrchestrator(hub, dnd, deliveryChannel);
20
+ });
21
+
22
+ describe('processTriggerEvents', () => {
23
+ it('should map new-email with urgency keyword to urgent priority', () => {
24
+ const event: TriggerEvent = {
25
+ triggerId: 'new-email',
26
+ connectorId: 'email-connector',
27
+ data: { subject: 'URGENT: Server down', from: 'ops@example.com' },
28
+ timestamp: Date.now(),
29
+ };
30
+
31
+ const results = orchestrator.processTriggerEvents([event]);
32
+
33
+ expect(results).toHaveLength(1);
34
+ expect(results[0].priority).toBe('urgent');
35
+ expect(results[0].message).toContain('ops@example.com');
36
+ expect(results[0].message).toContain('URGENT: Server down');
37
+ });
38
+
39
+ it('should detect various urgency keywords case-insensitively', () => {
40
+ const keywords = ['urgent', 'ASAP', 'Important', 'Action Required', 'DEADLINE'];
41
+
42
+ for (const keyword of keywords) {
43
+ const hub2 = new NotificationHub();
44
+ const delivered2: OrchestratorNotification[] = [];
45
+ const orch = new NotificationOrchestrator(hub2, dnd, (n) => delivered2.push(n));
46
+
47
+ const event: TriggerEvent = {
48
+ triggerId: 'new-email',
49
+ connectorId: 'email-connector',
50
+ data: { subject: `Re: ${keyword} meeting notes`, from: 'test@test.com' },
51
+ timestamp: Date.now(),
52
+ };
53
+
54
+ const results = orch.processTriggerEvents([event]);
55
+ expect(results[0].priority).toBe('urgent');
56
+ }
57
+ });
58
+
59
+ it('should map new-email without urgency keywords to important priority', () => {
60
+ const event: TriggerEvent = {
61
+ triggerId: 'new-email',
62
+ connectorId: 'email-connector',
63
+ data: { subject: 'Weekly newsletter', from: 'news@example.com' },
64
+ timestamp: Date.now(),
65
+ };
66
+
67
+ const results = orchestrator.processTriggerEvents([event]);
68
+
69
+ expect(results).toHaveLength(1);
70
+ expect(results[0].priority).toBe('important');
71
+ });
72
+
73
+ it('should map event-starting-soon to important priority', () => {
74
+ const event: TriggerEvent = {
75
+ triggerId: 'event-starting-soon',
76
+ connectorId: 'calendar-connector',
77
+ data: { title: 'Team standup' },
78
+ timestamp: Date.now(),
79
+ };
80
+
81
+ const results = orchestrator.processTriggerEvents([event]);
82
+
83
+ expect(results).toHaveLength(1);
84
+ expect(results[0].priority).toBe('important');
85
+ expect(results[0].message).toContain('Team standup');
86
+ expect(results[0].source).toBe('calendar');
87
+ });
88
+
89
+ it('should map file-shared to low priority', () => {
90
+ const event: TriggerEvent = {
91
+ triggerId: 'file-shared',
92
+ connectorId: 'drive-connector',
93
+ data: { fileName: 'report.pdf', sharedBy: 'Alice' },
94
+ timestamp: Date.now(),
95
+ };
96
+
97
+ const results = orchestrator.processTriggerEvents([event]);
98
+
99
+ expect(results).toHaveLength(1);
100
+ expect(results[0].priority).toBe('low');
101
+ expect(results[0].message).toContain('Alice');
102
+ expect(results[0].message).toContain('report.pdf');
103
+ });
104
+
105
+ it('should map unknown trigger events to low priority', () => {
106
+ const event: TriggerEvent = {
107
+ triggerId: 'some-unknown-trigger',
108
+ connectorId: 'custom-connector',
109
+ data: {},
110
+ timestamp: Date.now(),
111
+ };
112
+
113
+ const results = orchestrator.processTriggerEvents([event]);
114
+
115
+ expect(results).toHaveLength(1);
116
+ expect(results[0].priority).toBe('low');
117
+ expect(results[0].message).toContain('custom-connector');
118
+ });
119
+
120
+ it('should process multiple events at once', () => {
121
+ const events: TriggerEvent[] = [
122
+ {
123
+ triggerId: 'new-email',
124
+ connectorId: 'email',
125
+ data: { subject: 'Hello', from: 'a@b.com' },
126
+ timestamp: Date.now(),
127
+ },
128
+ {
129
+ triggerId: 'file-shared',
130
+ connectorId: 'drive',
131
+ data: { fileName: 'doc.txt', sharedBy: 'Bob' },
132
+ timestamp: Date.now(),
133
+ },
134
+ ];
135
+
136
+ const results = orchestrator.processTriggerEvents(events);
137
+ expect(results).toHaveLength(2);
138
+ });
139
+
140
+ it('should send notifications to the hub', () => {
141
+ const event: TriggerEvent = {
142
+ triggerId: 'new-email',
143
+ connectorId: 'email',
144
+ data: { subject: 'Test', from: 'x@y.com' },
145
+ timestamp: Date.now(),
146
+ };
147
+
148
+ orchestrator.processTriggerEvents([event]);
149
+
150
+ const hubNotifications = hub.getAll();
151
+ expect(hubNotifications).toHaveLength(1);
152
+ expect(hubNotifications[0].source).toBe('email');
153
+ });
154
+ });
155
+
156
+ describe('DND filtering', () => {
157
+ it('should deliver notifications when DND is inactive', () => {
158
+ const event: TriggerEvent = {
159
+ triggerId: 'new-email',
160
+ connectorId: 'email',
161
+ data: { subject: 'Hello', from: 'a@b.com' },
162
+ timestamp: Date.now(),
163
+ };
164
+
165
+ orchestrator.processTriggerEvents([event]);
166
+
167
+ expect(delivered).toHaveLength(1);
168
+ expect(delivered[0].delivered).toBe(true);
169
+ });
170
+
171
+ it('should queue non-urgent notifications when DND is active', () => {
172
+ dnd.setManual(60_000);
173
+
174
+ const event: TriggerEvent = {
175
+ triggerId: 'new-email',
176
+ connectorId: 'email',
177
+ data: { subject: 'Hello', from: 'a@b.com' },
178
+ timestamp: Date.now(),
179
+ };
180
+
181
+ orchestrator.processTriggerEvents([event]);
182
+
183
+ expect(delivered).toHaveLength(0);
184
+ expect(orchestrator.getPending()).toHaveLength(1);
185
+ expect(orchestrator.getPending()[0].delivered).toBe(false);
186
+ });
187
+
188
+ it('should allow urgent notifications through DND', () => {
189
+ dnd.setManual(60_000);
190
+
191
+ const event: TriggerEvent = {
192
+ triggerId: 'new-email',
193
+ connectorId: 'email',
194
+ data: { subject: 'URGENT: Production is down', from: 'ops@co.com' },
195
+ timestamp: Date.now(),
196
+ };
197
+
198
+ orchestrator.processTriggerEvents([event]);
199
+
200
+ expect(delivered).toHaveLength(1);
201
+ expect(delivered[0].priority).toBe('urgent');
202
+ });
203
+
204
+ it('should queue low-priority file-shared during DND', () => {
205
+ dnd.setManual(60_000);
206
+
207
+ const event: TriggerEvent = {
208
+ triggerId: 'file-shared',
209
+ connectorId: 'drive',
210
+ data: { fileName: 'notes.txt', sharedBy: 'Carol' },
211
+ timestamp: Date.now(),
212
+ };
213
+
214
+ orchestrator.processTriggerEvents([event]);
215
+
216
+ expect(delivered).toHaveLength(0);
217
+ expect(orchestrator.getPending()).toHaveLength(1);
218
+ });
219
+ });
220
+
221
+ describe('processCalendarCheck', () => {
222
+ it('should create notifications for events starting within alert window', () => {
223
+ const now = Date.now();
224
+ const events = [
225
+ { title: 'Team standup', startTime: now + 10 * 60_000 }, // 10 min
226
+ ];
227
+
228
+ const results = orchestrator.processCalendarCheck(events, now);
229
+
230
+ expect(results).toHaveLength(1);
231
+ expect(results[0].priority).toBe('important');
232
+ expect(results[0].message).toContain('Team standup');
233
+ expect(results[0].message).toContain('10 minutes');
234
+ });
235
+
236
+ it('should ignore events beyond the alert window', () => {
237
+ const now = Date.now();
238
+ const events = [
239
+ { title: 'Later meeting', startTime: now + 60 * 60_000 }, // 60 min
240
+ ];
241
+
242
+ const results = orchestrator.processCalendarCheck(events, now);
243
+ expect(results).toHaveLength(0);
244
+ });
245
+
246
+ it('should ignore events that have already started', () => {
247
+ const now = Date.now();
248
+ const events = [
249
+ { title: 'Past event', startTime: now - 5 * 60_000 },
250
+ ];
251
+
252
+ const results = orchestrator.processCalendarCheck(events, now);
253
+ expect(results).toHaveLength(0);
254
+ });
255
+
256
+ it('should use singular "minute" for 1 minute', () => {
257
+ const now = Date.now();
258
+ const events = [
259
+ { title: 'Quick sync', startTime: now + 60_000 }, // 1 min
260
+ ];
261
+
262
+ const results = orchestrator.processCalendarCheck(events, now);
263
+
264
+ expect(results).toHaveLength(1);
265
+ expect(results[0].message).toContain('1 minute');
266
+ expect(results[0].message).not.toContain('1 minutes');
267
+ });
268
+
269
+ it('should respect custom calendar alert window', () => {
270
+ const customOrch = new NotificationOrchestrator(hub, dnd, deliveryChannel, {
271
+ calendarAlertWindowMs: 5 * 60_000, // 5 minutes
272
+ });
273
+
274
+ const now = Date.now();
275
+ const events = [
276
+ { title: 'Soon event', startTime: now + 3 * 60_000 }, // 3 min — within
277
+ { title: 'Later event', startTime: now + 10 * 60_000 }, // 10 min — outside
278
+ ];
279
+
280
+ const results = customOrch.processCalendarCheck(events, now);
281
+ expect(results).toHaveLength(1);
282
+ expect(results[0].message).toContain('Soon event');
283
+ });
284
+
285
+ it('should send calendar notifications to the hub', () => {
286
+ const now = Date.now();
287
+ const events = [
288
+ { title: 'Sync', startTime: now + 5 * 60_000 },
289
+ ];
290
+
291
+ orchestrator.processCalendarCheck(events, now);
292
+
293
+ const hubNotifications = hub.getAll();
294
+ expect(hubNotifications).toHaveLength(1);
295
+ expect(hubNotifications[0].source).toBe('calendar');
296
+ expect(hubNotifications[0].priority).toBe('important');
297
+ });
298
+ });
299
+
300
+ describe('getPending and dismiss', () => {
301
+ it('should return empty array when no pending notifications', () => {
302
+ expect(orchestrator.getPending()).toHaveLength(0);
303
+ });
304
+
305
+ it('should dismiss a pending notification by ID', () => {
306
+ dnd.setManual(60_000);
307
+
308
+ const event: TriggerEvent = {
309
+ triggerId: 'file-shared',
310
+ connectorId: 'drive',
311
+ data: { fileName: 'test.txt', sharedBy: 'Dan' },
312
+ timestamp: Date.now(),
313
+ };
314
+
315
+ orchestrator.processTriggerEvents([event]);
316
+ const pending = orchestrator.getPending();
317
+ expect(pending).toHaveLength(1);
318
+
319
+ const dismissed = orchestrator.dismiss(pending[0].id);
320
+ expect(dismissed).toBe(true);
321
+ expect(orchestrator.getPending()).toHaveLength(0);
322
+ });
323
+
324
+ it('should return false when dismissing unknown ID', () => {
325
+ expect(orchestrator.dismiss('nonexistent-id')).toBe(false);
326
+ });
327
+
328
+ it('should not include delivered notifications in pending', () => {
329
+ const event: TriggerEvent = {
330
+ triggerId: 'new-email',
331
+ connectorId: 'email',
332
+ data: { subject: 'Hello', from: 'a@b.com' },
333
+ timestamp: Date.now(),
334
+ };
335
+
336
+ orchestrator.processTriggerEvents([event]);
337
+
338
+ // Delivered notifications should not appear in pending
339
+ expect(orchestrator.getPending()).toHaveLength(0);
340
+ expect(delivered).toHaveLength(1);
341
+ });
342
+ });
343
+ });
@@ -0,0 +1,310 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { AmbientScheduler, DEFAULT_AMBIENT_SCHEDULER_CONFIG } from '../src/scheduler.js';
3
+ import { BriefingGenerator, formatBriefingAsText } from '../src/briefing.js';
4
+ import type { AmbientSchedulerDeps } from '../src/scheduler.js';
5
+ import type { Briefing } from '../src/briefing.js';
6
+
7
+ function createMockScheduler() {
8
+ const jobs = new Map<string, { cron: string; callback: () => void }>();
9
+ return {
10
+ schedule: vi.fn((id: string, cron: string, callback: () => void) => {
11
+ jobs.set(id, { cron, callback });
12
+ }),
13
+ stop: vi.fn((id: string) => {
14
+ jobs.delete(id);
15
+ }),
16
+ stopAll: vi.fn(() => jobs.clear()),
17
+ isScheduled: vi.fn((id: string) => jobs.has(id)),
18
+ listScheduled: vi.fn(() => Array.from(jobs.keys())),
19
+ _jobs: jobs,
20
+ };
21
+ }
22
+
23
+ function createMockDeps(overrides?: Partial<AmbientSchedulerDeps>): AmbientSchedulerDeps & { _scheduler: ReturnType<typeof createMockScheduler> } {
24
+ const scheduler = createMockScheduler();
25
+ const deps: AmbientSchedulerDeps = {
26
+ scheduler: scheduler as any,
27
+ connectorRegistry: { get: vi.fn(), list: vi.fn(() => []), has: vi.fn() } as any,
28
+ triggerManager: { pollAll: vi.fn(async () => []), subscribe: vi.fn(), unsubscribe: vi.fn() } as any,
29
+ briefingGenerator: new BriefingGenerator(),
30
+ deliveryChannel: vi.fn(async () => {}),
31
+ userId: 'test-user',
32
+ ...overrides,
33
+ };
34
+ return { ...deps, _scheduler: scheduler };
35
+ }
36
+
37
+ describe('AmbientScheduler', () => {
38
+ let deps: ReturnType<typeof createMockDeps>;
39
+ let scheduler: AmbientScheduler;
40
+
41
+ beforeEach(() => {
42
+ deps = createMockDeps();
43
+ scheduler = new AmbientScheduler(deps);
44
+ });
45
+
46
+ it('should not be running initially', () => {
47
+ expect(scheduler.isRunning()).toBe(false);
48
+ });
49
+
50
+ it('should schedule 5 cron jobs on start', () => {
51
+ scheduler.start();
52
+ expect(deps._scheduler.schedule).toHaveBeenCalledTimes(5);
53
+ expect(scheduler.isRunning()).toBe(true);
54
+ });
55
+
56
+ it('should schedule jobs with correct cron expressions', () => {
57
+ scheduler.start();
58
+ const calls = deps._scheduler.schedule.mock.calls;
59
+ const jobMap = new Map(calls.map((c: any[]) => [c[0], c[1]]));
60
+ expect(jobMap.get('ambient:email-poll')).toBe('*/2 * * * *');
61
+ expect(jobMap.get('ambient:calendar-poll')).toBe('*/5 * * * *');
62
+ expect(jobMap.get('ambient:morning-briefing')).toBe('0 7 * * *');
63
+ expect(jobMap.get('ambient:evening-summary')).toBe('0 18 * * *');
64
+ expect(jobMap.get('ambient:notification-poll')).toBe('*/1 * * * *');
65
+ });
66
+
67
+ it('should use custom cron expressions from config', () => {
68
+ const custom = createMockDeps({
69
+ config: { morningCron: '0 6 * * *', eveningCron: '0 20 * * *' },
70
+ });
71
+ const s = new AmbientScheduler(custom);
72
+ s.start();
73
+ const calls = custom._scheduler.schedule.mock.calls;
74
+ const jobMap = new Map(calls.map((c: any[]) => [c[0], c[1]]));
75
+ expect(jobMap.get('ambient:morning-briefing')).toBe('0 6 * * *');
76
+ expect(jobMap.get('ambient:evening-summary')).toBe('0 20 * * *');
77
+ });
78
+
79
+ it('should not start if disabled', () => {
80
+ const disabled = createMockDeps({ config: { enabled: false } });
81
+ const s = new AmbientScheduler(disabled);
82
+ s.start();
83
+ expect(disabled._scheduler.schedule).not.toHaveBeenCalled();
84
+ expect(s.isRunning()).toBe(false);
85
+ });
86
+
87
+ it('should not start twice', () => {
88
+ scheduler.start();
89
+ scheduler.start();
90
+ expect(deps._scheduler.schedule).toHaveBeenCalledTimes(5);
91
+ });
92
+
93
+ it('should stop all jobs', () => {
94
+ scheduler.start();
95
+ scheduler.stop();
96
+ expect(deps._scheduler.stop).toHaveBeenCalledTimes(5);
97
+ expect(scheduler.isRunning()).toBe(false);
98
+ });
99
+
100
+ it('should return config', () => {
101
+ const config = scheduler.getConfig();
102
+ expect(config.morningCron).toBe(DEFAULT_AMBIENT_SCHEDULER_CONFIG.morningCron);
103
+ expect(config.enabled).toBe(true);
104
+ });
105
+
106
+ it('should poll triggers when email poll fires', () => {
107
+ scheduler.start();
108
+ const emailPoll = deps._scheduler._jobs.get('ambient:email-poll');
109
+ emailPoll!.callback();
110
+ expect(deps.triggerManager.pollAll).toHaveBeenCalled();
111
+ });
112
+
113
+ it('should generate and deliver morning briefing', async () => {
114
+ scheduler.start();
115
+ await scheduler.generateAndDeliverBriefing('morning');
116
+ expect(deps.deliveryChannel).toHaveBeenCalledTimes(1);
117
+ const delivered = (deps.deliveryChannel as any).mock.calls[0][0] as string;
118
+ expect(delivered).toContain('Good morning');
119
+ });
120
+
121
+ it('should generate and deliver evening briefing', async () => {
122
+ scheduler.start();
123
+ await scheduler.generateAndDeliverBriefing('evening');
124
+ expect(deps.deliveryChannel).toHaveBeenCalledTimes(1);
125
+ const delivered = (deps.deliveryChannel as any).mock.calls[0][0] as string;
126
+ expect(delivered).toContain('evening summary');
127
+ });
128
+
129
+ it('should include calendar events when calendarIntelligence is available', async () => {
130
+ const calDeps = createMockDeps({
131
+ calendarIntelligence: {
132
+ analyzeDay: vi.fn(async () => ({
133
+ events: [{ title: 'Team standup', time: '10:00' }],
134
+ })),
135
+ },
136
+ });
137
+ const s = new AmbientScheduler(calDeps);
138
+ await s.generateAndDeliverBriefing('morning');
139
+ const delivered = (calDeps.deliveryChannel as any).mock.calls[0][0] as string;
140
+ expect(delivered).toContain('Team standup');
141
+ });
142
+
143
+ it('should include email notifications when emailIntelligence is available', async () => {
144
+ const emailDeps = createMockDeps({
145
+ emailIntelligence: {
146
+ triage: {
147
+ getTriageSummary: vi.fn(async () => ({
148
+ items: [{ subject: 'Urgent: server down', priority: 'urgent' }],
149
+ })),
150
+ },
151
+ },
152
+ });
153
+ const s = new AmbientScheduler(emailDeps);
154
+ await s.generateAndDeliverBriefing('morning');
155
+ const delivered = (emailDeps.deliveryChannel as any).mock.calls[0][0] as string;
156
+ expect(delivered).toContain('Urgent: server down');
157
+ });
158
+
159
+ it('should handle calendar fetch errors gracefully', async () => {
160
+ const errorDeps = createMockDeps({
161
+ calendarIntelligence: {
162
+ analyzeDay: vi.fn(async () => { throw new Error('API error'); }),
163
+ },
164
+ });
165
+ const s = new AmbientScheduler(errorDeps);
166
+ await expect(s.generateAndDeliverBriefing('morning')).resolves.toBeUndefined();
167
+ expect(errorDeps.deliveryChannel).toHaveBeenCalled();
168
+ });
169
+
170
+ it('should handle email fetch errors gracefully', async () => {
171
+ const errorDeps = createMockDeps({
172
+ emailIntelligence: {
173
+ triage: {
174
+ getTriageSummary: vi.fn(async () => { throw new Error('API error'); }),
175
+ },
176
+ },
177
+ });
178
+ const s = new AmbientScheduler(errorDeps);
179
+ await expect(s.generateAndDeliverBriefing('morning')).resolves.toBeUndefined();
180
+ expect(errorDeps.deliveryChannel).toHaveBeenCalled();
181
+ });
182
+
183
+ it('should schedule notification poll job on start', () => {
184
+ scheduler.start();
185
+ const jobs = Array.from(deps._scheduler._jobs.keys());
186
+ expect(jobs).toContain('ambient:notification-poll');
187
+ });
188
+
189
+ it('should stop notification poll job on stop', () => {
190
+ scheduler.start();
191
+ scheduler.stop();
192
+ expect(deps._scheduler.stop).toHaveBeenCalledWith('ambient:notification-poll');
193
+ });
194
+
195
+ it('should call triggerManager.pollAll and orchestrator when notification poll fires', async () => {
196
+ const mockOrchestrator = {
197
+ processTriggerEvents: vi.fn(),
198
+ processCalendarCheck: vi.fn(),
199
+ getPending: vi.fn(() => []),
200
+ dismiss: vi.fn(),
201
+ };
202
+ const orchDeps = createMockDeps({
203
+ notificationOrchestrator: mockOrchestrator as any,
204
+ });
205
+ (orchDeps.triggerManager.pollAll as any).mockResolvedValue([
206
+ { triggerId: 'new-email', connectorId: 'email', data: { subject: 'Test' }, timestamp: Date.now() },
207
+ ]);
208
+ const s = new AmbientScheduler(orchDeps);
209
+ s.start();
210
+
211
+ const pollJob = orchDeps._scheduler._jobs.get('ambient:notification-poll');
212
+ pollJob!.callback();
213
+
214
+ // Wait for async pollAndNotify to complete
215
+ await vi.waitFor(() => {
216
+ expect(orchDeps.triggerManager.pollAll).toHaveBeenCalled();
217
+ expect(mockOrchestrator.processTriggerEvents).toHaveBeenCalledWith([
218
+ expect.objectContaining({ triggerId: 'new-email' }),
219
+ ]);
220
+ });
221
+ });
222
+
223
+ it('should not call orchestrator when no events are returned', async () => {
224
+ const mockOrchestrator = {
225
+ processTriggerEvents: vi.fn(),
226
+ processCalendarCheck: vi.fn(),
227
+ getPending: vi.fn(() => []),
228
+ dismiss: vi.fn(),
229
+ };
230
+ const orchDeps = createMockDeps({
231
+ notificationOrchestrator: mockOrchestrator as any,
232
+ });
233
+ (orchDeps.triggerManager.pollAll as any).mockResolvedValue([]);
234
+ const s = new AmbientScheduler(orchDeps);
235
+ s.start();
236
+
237
+ const pollJob = orchDeps._scheduler._jobs.get('ambient:notification-poll');
238
+ pollJob!.callback();
239
+
240
+ await vi.waitFor(() => {
241
+ expect(orchDeps.triggerManager.pollAll).toHaveBeenCalled();
242
+ });
243
+ expect(mockOrchestrator.processTriggerEvents).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it('should skip notification polling when orchestrator is not provided', async () => {
247
+ scheduler.start();
248
+ const pollJob = deps._scheduler._jobs.get('ambient:notification-poll');
249
+ pollJob!.callback();
250
+ // Should not throw; pollAll should not be called since orchestrator is absent
251
+ await vi.waitFor(() => {
252
+ expect(deps.triggerManager.pollAll).not.toHaveBeenCalled();
253
+ });
254
+ });
255
+ });
256
+
257
+ describe('formatBriefingAsText', () => {
258
+ it('should format a morning briefing', () => {
259
+ const briefing: Briefing = {
260
+ userId: 'user1',
261
+ generatedAt: Date.now(),
262
+ timeOfDay: 'morning',
263
+ sections: [
264
+ { title: "Today's Schedule", items: ['10:00 - Standup', '12:00 - Lunch'] },
265
+ { title: 'Active Tasks', items: ['Review PR (in-progress)'] },
266
+ ],
267
+ };
268
+ const text = formatBriefingAsText(briefing);
269
+ expect(text).toContain('Good morning');
270
+ expect(text).toContain("Today's Schedule");
271
+ expect(text).toContain(' 10:00 - Standup');
272
+ expect(text).toContain(' 12:00 - Lunch');
273
+ expect(text).toContain('Active Tasks');
274
+ expect(text).toContain(' Review PR (in-progress)');
275
+ });
276
+
277
+ it('should format an evening briefing', () => {
278
+ const briefing: Briefing = {
279
+ userId: 'user1',
280
+ generatedAt: Date.now(),
281
+ timeOfDay: 'evening',
282
+ sections: [{ title: "Tomorrow's Schedule", items: ['09:00 - Sprint planning'] }],
283
+ };
284
+ const text = formatBriefingAsText(briefing);
285
+ expect(text).toContain('evening summary');
286
+ expect(text).toContain("Tomorrow's Schedule");
287
+ });
288
+
289
+ it('should format a custom briefing', () => {
290
+ const briefing: Briefing = {
291
+ userId: 'user1',
292
+ generatedAt: Date.now(),
293
+ timeOfDay: 'custom',
294
+ sections: [{ title: 'Updates', items: ['Item 1'] }],
295
+ };
296
+ const text = formatBriefingAsText(briefing);
297
+ expect(text).toContain('Here\'s your briefing:');
298
+ });
299
+
300
+ it('should handle empty sections', () => {
301
+ const briefing: Briefing = {
302
+ userId: 'user1',
303
+ generatedAt: Date.now(),
304
+ timeOfDay: 'morning',
305
+ sections: [],
306
+ };
307
+ const text = formatBriefingAsText(briefing);
308
+ expect(text).toContain('No updates right now');
309
+ });
310
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ describe('Ambient package exports', () => {
4
+ it('should export all public APIs', async () => {
5
+ const mod = await import('../src/index.js');
6
+ expect(mod.AmbientPatternEngine).toBeDefined();
7
+ expect(mod.AnticipationEngine).toBeDefined();
8
+ expect(mod.BriefingGenerator).toBeDefined();
9
+ expect(mod.QuietNotificationManager).toBeDefined();
10
+ expect(mod.DEFAULT_BRIEFING_CONFIG).toBeDefined();
11
+ });
12
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../behaviors" },
10
+ { "path": "../memory" },
11
+ { "path": "../connectors" },
12
+ { "path": "../notification-hub" },
13
+ { "path": "../personality" }
14
+ ]
15
+ }