@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.
- package/LICENSE +191 -0
- package/dist/anticipation.d.ts +27 -0
- package/dist/anticipation.d.ts.map +1 -0
- package/dist/anticipation.js +128 -0
- package/dist/anticipation.js.map +1 -0
- package/dist/briefing.d.ts +43 -0
- package/dist/briefing.d.ts.map +1 -0
- package/dist/briefing.js +109 -0
- package/dist/briefing.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/notification.d.ts +29 -0
- package/dist/notification.d.ts.map +1 -0
- package/dist/notification.js +87 -0
- package/dist/notification.js.map +1 -0
- package/dist/orchestrator.d.ts +40 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +130 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pattern-engine.d.ts +27 -0
- package/dist/pattern-engine.d.ts.map +1 -0
- package/dist/pattern-engine.js +187 -0
- package/dist/pattern-engine.js.map +1 -0
- package/dist/scheduler.d.ts +88 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +172 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +29 -0
- package/src/anticipation.ts +141 -0
- package/src/briefing.ts +152 -0
- package/src/index.ts +26 -0
- package/src/notification.ts +101 -0
- package/src/orchestrator.ts +188 -0
- package/src/pattern-engine.ts +212 -0
- package/src/scheduler.ts +238 -0
- package/src/types.ts +85 -0
- package/tests/ambient.test.ts +363 -0
- package/tests/orchestrator.test.ts +343 -0
- package/tests/scheduler.test.ts +310 -0
- package/tests/wiring.test.ts +12 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Scheduler } from '@auxiora/behaviors';
|
|
2
|
+
import type { ConnectorRegistry, TriggerManager } from '@auxiora/connectors';
|
|
3
|
+
import type { BriefingGenerator } from './briefing.js';
|
|
4
|
+
import type { NotificationOrchestrator } from './orchestrator.js';
|
|
5
|
+
import type { QuietNotification } from './types.js';
|
|
6
|
+
import { formatBriefingAsText } from './briefing.js';
|
|
7
|
+
|
|
8
|
+
/** Configuration for the ambient scheduler. */
|
|
9
|
+
export interface AmbientSchedulerConfig {
|
|
10
|
+
/** Cron expression for morning briefing. */
|
|
11
|
+
morningCron: string;
|
|
12
|
+
/** Cron expression for evening summary. */
|
|
13
|
+
eveningCron: string;
|
|
14
|
+
/** Cron expression for email polling. */
|
|
15
|
+
emailPollCron: string;
|
|
16
|
+
/** Cron expression for calendar polling. */
|
|
17
|
+
calendarPollCron: string;
|
|
18
|
+
/** Cron expression for notification polling. */
|
|
19
|
+
notificationPollCron: string;
|
|
20
|
+
/** Calendar alert window in minutes. */
|
|
21
|
+
calendarAlertMinutes: number;
|
|
22
|
+
/** Whether the scheduler is enabled. */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Categories to include in briefings. */
|
|
25
|
+
categories: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_AMBIENT_SCHEDULER_CONFIG: AmbientSchedulerConfig = {
|
|
29
|
+
morningCron: '0 7 * * *',
|
|
30
|
+
eveningCron: '0 18 * * *',
|
|
31
|
+
emailPollCron: '*/2 * * * *',
|
|
32
|
+
calendarPollCron: '*/5 * * * *',
|
|
33
|
+
notificationPollCron: '*/1 * * * *',
|
|
34
|
+
calendarAlertMinutes: 15,
|
|
35
|
+
enabled: true,
|
|
36
|
+
categories: ['calendar', 'email', 'tasks'],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Dependencies for the ambient scheduler. */
|
|
40
|
+
export interface AmbientSchedulerDeps {
|
|
41
|
+
scheduler: Scheduler;
|
|
42
|
+
connectorRegistry: ConnectorRegistry;
|
|
43
|
+
triggerManager: TriggerManager;
|
|
44
|
+
briefingGenerator: BriefingGenerator;
|
|
45
|
+
emailIntelligence?: { triage?: { getTriageSummary(opts: { maxResults: number }): Promise<{ items: Array<{ subject: string; priority: string }> }> } };
|
|
46
|
+
calendarIntelligence?: { analyzeDay(date: string): Promise<{ events: Array<{ title: string; time: string }> }> };
|
|
47
|
+
notificationOrchestrator?: NotificationOrchestrator;
|
|
48
|
+
deliveryChannel: (message: string) => Promise<void>;
|
|
49
|
+
userId: string;
|
|
50
|
+
config?: Partial<AmbientSchedulerConfig>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const JOB_IDS = {
|
|
54
|
+
emailPoll: 'ambient:email-poll',
|
|
55
|
+
calendarPoll: 'ambient:calendar-poll',
|
|
56
|
+
morningBriefing: 'ambient:morning-briefing',
|
|
57
|
+
eveningSummary: 'ambient:evening-summary',
|
|
58
|
+
notificationPoll: 'ambient:notification-poll',
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Schedules ambient polling and briefing generation using cron jobs.
|
|
63
|
+
*/
|
|
64
|
+
export class AmbientScheduler {
|
|
65
|
+
private readonly scheduler: Scheduler;
|
|
66
|
+
private readonly connectorRegistry: ConnectorRegistry;
|
|
67
|
+
private readonly triggerManager: TriggerManager;
|
|
68
|
+
private readonly briefingGenerator: BriefingGenerator;
|
|
69
|
+
private readonly emailIntelligence: AmbientSchedulerDeps['emailIntelligence'];
|
|
70
|
+
private readonly calendarIntelligence: AmbientSchedulerDeps['calendarIntelligence'];
|
|
71
|
+
private readonly notificationOrchestrator: NotificationOrchestrator | undefined;
|
|
72
|
+
private readonly deliveryChannel: (message: string) => Promise<void>;
|
|
73
|
+
private readonly userId: string;
|
|
74
|
+
private readonly config: AmbientSchedulerConfig;
|
|
75
|
+
private running = false;
|
|
76
|
+
|
|
77
|
+
constructor(deps: AmbientSchedulerDeps) {
|
|
78
|
+
this.scheduler = deps.scheduler;
|
|
79
|
+
this.connectorRegistry = deps.connectorRegistry;
|
|
80
|
+
this.triggerManager = deps.triggerManager;
|
|
81
|
+
this.briefingGenerator = deps.briefingGenerator;
|
|
82
|
+
this.emailIntelligence = deps.emailIntelligence;
|
|
83
|
+
this.calendarIntelligence = deps.calendarIntelligence;
|
|
84
|
+
this.notificationOrchestrator = deps.notificationOrchestrator;
|
|
85
|
+
this.deliveryChannel = deps.deliveryChannel;
|
|
86
|
+
this.userId = deps.userId;
|
|
87
|
+
this.config = { ...DEFAULT_AMBIENT_SCHEDULER_CONFIG, ...deps.config };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Start all scheduled cron jobs. */
|
|
91
|
+
start(): void {
|
|
92
|
+
if (!this.config.enabled || this.running) return;
|
|
93
|
+
|
|
94
|
+
this.scheduler.schedule(JOB_IDS.emailPoll, this.config.emailPollCron, () => {
|
|
95
|
+
void this.triggerManager.pollAll();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.scheduler.schedule(JOB_IDS.calendarPoll, this.config.calendarPollCron, () => {
|
|
99
|
+
void this.pollCalendar();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.scheduler.schedule(JOB_IDS.morningBriefing, this.config.morningCron, () => {
|
|
103
|
+
void this.generateAndDeliverBriefing('morning');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
this.scheduler.schedule(JOB_IDS.eveningSummary, this.config.eveningCron, () => {
|
|
107
|
+
void this.generateAndDeliverBriefing('evening');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.scheduler.schedule(JOB_IDS.notificationPoll, this.config.notificationPollCron, () => {
|
|
111
|
+
void this.pollAndNotify();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.running = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stop all scheduled cron jobs. */
|
|
118
|
+
stop(): void {
|
|
119
|
+
this.scheduler.stop(JOB_IDS.emailPoll);
|
|
120
|
+
this.scheduler.stop(JOB_IDS.calendarPoll);
|
|
121
|
+
this.scheduler.stop(JOB_IDS.morningBriefing);
|
|
122
|
+
this.scheduler.stop(JOB_IDS.eveningSummary);
|
|
123
|
+
this.scheduler.stop(JOB_IDS.notificationPoll);
|
|
124
|
+
this.running = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether the scheduler is currently running. */
|
|
128
|
+
isRunning(): boolean {
|
|
129
|
+
return this.running;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get the current configuration. */
|
|
133
|
+
getConfig(): AmbientSchedulerConfig {
|
|
134
|
+
return { ...this.config };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Generate and deliver a briefing for the specified time of day. */
|
|
138
|
+
async generateAndDeliverBriefing(time: 'morning' | 'evening'): Promise<void> {
|
|
139
|
+
const calendarEvents = await this.fetchCalendarEvents(time);
|
|
140
|
+
const emailNotifications = await this.fetchEmailSummary();
|
|
141
|
+
|
|
142
|
+
const briefing = this.briefingGenerator.generateBriefing(
|
|
143
|
+
this.userId,
|
|
144
|
+
time,
|
|
145
|
+
{
|
|
146
|
+
calendarEvents,
|
|
147
|
+
notifications: emailNotifications,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const formatted = formatBriefingAsText(briefing);
|
|
152
|
+
await this.deliveryChannel(formatted);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async pollAndNotify(): Promise<void> {
|
|
156
|
+
if (!this.notificationOrchestrator) return;
|
|
157
|
+
|
|
158
|
+
const events = await this.triggerManager.pollAll();
|
|
159
|
+
if (events.length > 0) {
|
|
160
|
+
this.notificationOrchestrator.processTriggerEvents(events);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check for upcoming calendar events
|
|
164
|
+
if (this.calendarIntelligence) {
|
|
165
|
+
try {
|
|
166
|
+
const today = new Date().toISOString().split('T')[0]!;
|
|
167
|
+
const result = await this.calendarIntelligence.analyzeDay(today);
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const alertWindowMs = this.config.calendarAlertMinutes * 60_000;
|
|
170
|
+
|
|
171
|
+
const upcomingEvents = (result.events ?? [])
|
|
172
|
+
.map((e) => ({
|
|
173
|
+
title: e.title,
|
|
174
|
+
startTime: this.parseTimeToTimestamp(e.time),
|
|
175
|
+
}))
|
|
176
|
+
.filter((e) => {
|
|
177
|
+
const timeUntil = e.startTime - now;
|
|
178
|
+
return timeUntil > 0 && timeUntil <= alertWindowMs;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (upcomingEvents.length > 0) {
|
|
182
|
+
this.notificationOrchestrator.processCalendarCheck(upcomingEvents, now);
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Calendar fetch errors are silently ignored
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private parseTimeToTimestamp(time: string): number {
|
|
191
|
+
const [hours, minutes] = time.split(':').map(Number);
|
|
192
|
+
const d = new Date();
|
|
193
|
+
d.setHours(hours ?? 0, minutes ?? 0, 0, 0);
|
|
194
|
+
return d.getTime();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async pollCalendar(): Promise<void> {
|
|
198
|
+
if (!this.calendarIntelligence) return;
|
|
199
|
+
|
|
200
|
+
const today = new Date().toISOString().split('T')[0]!;
|
|
201
|
+
await this.calendarIntelligence.analyzeDay(today);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async fetchCalendarEvents(
|
|
205
|
+
time: 'morning' | 'evening',
|
|
206
|
+
): Promise<Array<{ title: string; time: string }>> {
|
|
207
|
+
if (!this.calendarIntelligence) return [];
|
|
208
|
+
|
|
209
|
+
const targetDate = time === 'evening'
|
|
210
|
+
? new Date(Date.now() + 86_400_000).toISOString().split('T')[0]!
|
|
211
|
+
: new Date().toISOString().split('T')[0]!;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const result = await this.calendarIntelligence.analyzeDay(targetDate);
|
|
215
|
+
return result.events ?? [];
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async fetchEmailSummary(): Promise<QuietNotification[]> {
|
|
222
|
+
if (!this.emailIntelligence?.triage) return [];
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const result = await this.emailIntelligence.triage.getTriageSummary({ maxResults: 10 });
|
|
226
|
+
return (result.items ?? []).map((item, i) => ({
|
|
227
|
+
id: `email-${i}`,
|
|
228
|
+
priority: item.priority === 'urgent' ? 'alert' as const : 'nudge' as const,
|
|
229
|
+
message: item.subject,
|
|
230
|
+
createdAt: Date.now(),
|
|
231
|
+
dismissed: false,
|
|
232
|
+
source: 'email',
|
|
233
|
+
}));
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Types of patterns the ambient engine can detect. */
|
|
2
|
+
export type AmbientPatternType = 'schedule' | 'preference' | 'trigger' | 'correlation';
|
|
3
|
+
|
|
4
|
+
/** Notification priority levels for quiet notifications. */
|
|
5
|
+
export type NotificationPriority = 'whisper' | 'nudge' | 'alert';
|
|
6
|
+
|
|
7
|
+
/** A detected ambient pattern from user behavior. */
|
|
8
|
+
export interface AmbientPattern {
|
|
9
|
+
id: string;
|
|
10
|
+
/** Pattern type. */
|
|
11
|
+
type: AmbientPatternType;
|
|
12
|
+
/** Human-readable description. */
|
|
13
|
+
description: string;
|
|
14
|
+
/** Confidence score (0-1). */
|
|
15
|
+
confidence: number;
|
|
16
|
+
/** Evidence supporting this pattern. */
|
|
17
|
+
evidence: string[];
|
|
18
|
+
/** When the pattern was first detected. */
|
|
19
|
+
detectedAt: number;
|
|
20
|
+
/** When the pattern was last confirmed. */
|
|
21
|
+
lastConfirmedAt: number;
|
|
22
|
+
/** Number of times this pattern has been observed. */
|
|
23
|
+
occurrences: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An anticipated user need based on detected patterns. */
|
|
27
|
+
export interface Anticipation {
|
|
28
|
+
id: string;
|
|
29
|
+
/** What the user might need. */
|
|
30
|
+
description: string;
|
|
31
|
+
/** When this need is expected. */
|
|
32
|
+
expectedAt: number;
|
|
33
|
+
/** Confidence that this anticipation is correct (0-1). */
|
|
34
|
+
confidence: number;
|
|
35
|
+
/** Patterns that led to this anticipation. */
|
|
36
|
+
sourcePatterns: string[];
|
|
37
|
+
/** Suggested action to fulfill the need. */
|
|
38
|
+
suggestedAction?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A quiet notification queued for the user. */
|
|
42
|
+
export interface QuietNotification {
|
|
43
|
+
id: string;
|
|
44
|
+
/** Priority level. */
|
|
45
|
+
priority: NotificationPriority;
|
|
46
|
+
/** Notification message. */
|
|
47
|
+
message: string;
|
|
48
|
+
/** Detailed content (optional). */
|
|
49
|
+
detail?: string;
|
|
50
|
+
/** When the notification was created. */
|
|
51
|
+
createdAt: number;
|
|
52
|
+
/** Whether the notification has been dismissed. */
|
|
53
|
+
dismissed: boolean;
|
|
54
|
+
/** Source of the notification. */
|
|
55
|
+
source: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Configuration for personalized briefings. */
|
|
59
|
+
export interface BriefingConfig {
|
|
60
|
+
/** Whether briefings are enabled. */
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
/** Time of day for morning briefing (HH:MM). */
|
|
63
|
+
morningTime: string;
|
|
64
|
+
/** Time of day for evening summary (HH:MM). */
|
|
65
|
+
eveningTime: string;
|
|
66
|
+
/** Categories to include in briefings. */
|
|
67
|
+
categories: string[];
|
|
68
|
+
/** Maximum number of items per section. */
|
|
69
|
+
maxItemsPerSection: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
|
73
|
+
enabled: true,
|
|
74
|
+
morningTime: '08:00',
|
|
75
|
+
eveningTime: '20:00',
|
|
76
|
+
categories: ['calendar', 'tasks', 'weather', 'news', 'patterns'],
|
|
77
|
+
maxItemsPerSection: 5,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** An observed event for the pattern engine. */
|
|
81
|
+
export interface ObservedEvent {
|
|
82
|
+
type: string;
|
|
83
|
+
timestamp: number;
|
|
84
|
+
data?: Record<string, unknown>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { AmbientPatternEngine } from '../src/pattern-engine.js';
|
|
3
|
+
import { AnticipationEngine } from '../src/anticipation.js';
|
|
4
|
+
import { BriefingGenerator } from '../src/briefing.js';
|
|
5
|
+
import { QuietNotificationManager } from '../src/notification.js';
|
|
6
|
+
import type { AmbientPattern } from '../src/types.js';
|
|
7
|
+
|
|
8
|
+
describe('AmbientPatternEngine', () => {
|
|
9
|
+
let engine: AmbientPatternEngine;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
engine = new AmbientPatternEngine();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should start with no events', () => {
|
|
16
|
+
expect(engine.getEventCount()).toBe(0);
|
|
17
|
+
expect(engine.getPatterns()).toHaveLength(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should observe events', () => {
|
|
21
|
+
engine.observe({ type: 'coffee', timestamp: Date.now() });
|
|
22
|
+
engine.observe({ type: 'coffee', timestamp: Date.now() });
|
|
23
|
+
expect(engine.getEventCount()).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should detect schedule patterns from repeated events at same hour', () => {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const hour = new Date(now).getHours();
|
|
29
|
+
|
|
30
|
+
// Create events at same hour across multiple days
|
|
31
|
+
for (let i = 0; i < 5; i++) {
|
|
32
|
+
const d = new Date(now);
|
|
33
|
+
d.setDate(d.getDate() - i);
|
|
34
|
+
d.setHours(hour, 0, 0, 0);
|
|
35
|
+
engine.observe({ type: 'standup', timestamp: d.getTime() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patterns = engine.detectPatterns();
|
|
39
|
+
expect(patterns.length).toBeGreaterThan(0);
|
|
40
|
+
const schedule = patterns.find(p => p.type === 'schedule');
|
|
41
|
+
expect(schedule).toBeDefined();
|
|
42
|
+
expect(schedule!.description).toContain('standup');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should detect frequency patterns from regularly spaced events', () => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const interval = 2 * 60 * 60 * 1000; // 2 hours
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < 5; i++) {
|
|
50
|
+
engine.observe({ type: 'check-email', timestamp: now - i * interval });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const patterns = engine.detectPatterns();
|
|
54
|
+
const freq = patterns.find(p => p.type === 'preference');
|
|
55
|
+
expect(freq).toBeDefined();
|
|
56
|
+
expect(freq!.description).toContain('check-email');
|
|
57
|
+
expect(freq!.description).toContain('hours');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should detect correlations between event types', () => {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
|
|
63
|
+
// A always followed by B within 5 minutes
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
const base = now - i * 60 * 60 * 1000;
|
|
66
|
+
engine.observe({ type: 'open-slack', timestamp: base });
|
|
67
|
+
engine.observe({ type: 'check-calendar', timestamp: base + 2 * 60 * 1000 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const patterns = engine.detectPatterns();
|
|
71
|
+
const correlation = patterns.find(p => p.type === 'correlation');
|
|
72
|
+
expect(correlation).toBeDefined();
|
|
73
|
+
expect(correlation!.description).toContain('follows');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should prune events outside window', () => {
|
|
77
|
+
// Use a tiny window (1 second)
|
|
78
|
+
const shortEngine = new AmbientPatternEngine(1000);
|
|
79
|
+
shortEngine.observe({ type: 'old', timestamp: Date.now() - 5000 });
|
|
80
|
+
shortEngine.observe({ type: 'new', timestamp: Date.now() });
|
|
81
|
+
// Prune happens on next observe
|
|
82
|
+
shortEngine.observe({ type: 'new2', timestamp: Date.now() });
|
|
83
|
+
expect(shortEngine.getEventCount()).toBe(2); // 'old' pruned
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reset state', () => {
|
|
87
|
+
engine.observe({ type: 'test', timestamp: Date.now() });
|
|
88
|
+
engine.reset();
|
|
89
|
+
expect(engine.getEventCount()).toBe(0);
|
|
90
|
+
expect(engine.getPatterns()).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('AnticipationEngine', () => {
|
|
95
|
+
let anticipation: AnticipationEngine;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
anticipation = new AnticipationEngine();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should generate anticipations from schedule patterns', () => {
|
|
102
|
+
const pattern: AmbientPattern = {
|
|
103
|
+
id: 'p1',
|
|
104
|
+
type: 'schedule',
|
|
105
|
+
description: '"standup" events frequently occur around 10:00',
|
|
106
|
+
confidence: 0.8,
|
|
107
|
+
evidence: [],
|
|
108
|
+
detectedAt: Date.now(),
|
|
109
|
+
lastConfirmedAt: Date.now(),
|
|
110
|
+
occurrences: 5,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const results = anticipation.generateAnticipations([pattern]);
|
|
114
|
+
expect(results.length).toBeGreaterThan(0);
|
|
115
|
+
expect(results[0].sourcePatterns).toContain('p1');
|
|
116
|
+
expect(results[0].expectedAt).toBeGreaterThan(Date.now());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should generate anticipations from preference patterns', () => {
|
|
120
|
+
const pattern: AmbientPattern = {
|
|
121
|
+
id: 'p2',
|
|
122
|
+
type: 'preference',
|
|
123
|
+
description: '"email" occurs roughly every 2.5 hours',
|
|
124
|
+
confidence: 0.7,
|
|
125
|
+
evidence: [],
|
|
126
|
+
detectedAt: Date.now(),
|
|
127
|
+
lastConfirmedAt: Date.now(),
|
|
128
|
+
occurrences: 4,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const results = anticipation.generateAnticipations([pattern]);
|
|
132
|
+
expect(results.length).toBeGreaterThan(0);
|
|
133
|
+
expect(results[0].expectedAt).toBeGreaterThan(Date.now());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should skip low-confidence patterns', () => {
|
|
137
|
+
const pattern: AmbientPattern = {
|
|
138
|
+
id: 'p3',
|
|
139
|
+
type: 'schedule',
|
|
140
|
+
description: '"rare" around 14:00',
|
|
141
|
+
confidence: 0.2,
|
|
142
|
+
evidence: [],
|
|
143
|
+
detectedAt: Date.now(),
|
|
144
|
+
lastConfirmedAt: Date.now(),
|
|
145
|
+
occurrences: 1,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const results = anticipation.generateAnticipations([pattern]);
|
|
149
|
+
expect(results).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should prune expired anticipations', () => {
|
|
153
|
+
const pattern: AmbientPattern = {
|
|
154
|
+
id: 'p4',
|
|
155
|
+
type: 'correlation',
|
|
156
|
+
description: '"B" often follows "A" within 5 minutes',
|
|
157
|
+
confidence: 0.8,
|
|
158
|
+
evidence: [],
|
|
159
|
+
detectedAt: Date.now(),
|
|
160
|
+
lastConfirmedAt: Date.now(),
|
|
161
|
+
occurrences: 3,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Generate with a past time so anticipations expire immediately
|
|
165
|
+
anticipation.generateAnticipations([pattern], { currentTime: Date.now() - 10 * 60 * 1000 });
|
|
166
|
+
const pruned = anticipation.prune();
|
|
167
|
+
expect(pruned).toBeGreaterThanOrEqual(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should reset', () => {
|
|
171
|
+
const pattern: AmbientPattern = {
|
|
172
|
+
id: 'p5',
|
|
173
|
+
type: 'trigger',
|
|
174
|
+
description: 'trigger detected',
|
|
175
|
+
confidence: 0.9,
|
|
176
|
+
evidence: [],
|
|
177
|
+
detectedAt: Date.now(),
|
|
178
|
+
lastConfirmedAt: Date.now(),
|
|
179
|
+
occurrences: 1,
|
|
180
|
+
};
|
|
181
|
+
anticipation.generateAnticipations([pattern]);
|
|
182
|
+
anticipation.reset();
|
|
183
|
+
expect(anticipation.getAnticipations()).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('BriefingGenerator', () => {
|
|
188
|
+
let generator: BriefingGenerator;
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
generator = new BriefingGenerator();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should generate a morning briefing with sections', () => {
|
|
195
|
+
const briefing = generator.generateBriefing('user1', 'morning', {
|
|
196
|
+
notifications: [
|
|
197
|
+
{ id: 'n1', priority: 'nudge', message: 'New email', createdAt: Date.now(), dismissed: false, source: 'email' },
|
|
198
|
+
],
|
|
199
|
+
calendarEvents: [
|
|
200
|
+
{ title: 'Team standup', time: '10:00' },
|
|
201
|
+
{ title: 'Lunch', time: '12:00' },
|
|
202
|
+
],
|
|
203
|
+
tasks: [
|
|
204
|
+
{ title: 'Review PR', status: 'in-progress' },
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(briefing.userId).toBe('user1');
|
|
209
|
+
expect(briefing.timeOfDay).toBe('morning');
|
|
210
|
+
expect(briefing.sections.length).toBeGreaterThan(0);
|
|
211
|
+
|
|
212
|
+
const calendarSection = briefing.sections.find(s => s.title.includes('Schedule'));
|
|
213
|
+
expect(calendarSection).toBeDefined();
|
|
214
|
+
expect(calendarSection!.items).toHaveLength(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should include patterns section when patterns are available', () => {
|
|
218
|
+
const briefing = generator.generateBriefing('user1', 'evening', {
|
|
219
|
+
patterns: [
|
|
220
|
+
{
|
|
221
|
+
id: 'p1',
|
|
222
|
+
type: 'schedule',
|
|
223
|
+
description: 'Standup at 10:00',
|
|
224
|
+
confidence: 0.8,
|
|
225
|
+
evidence: [],
|
|
226
|
+
detectedAt: Date.now(),
|
|
227
|
+
lastConfirmedAt: Date.now(),
|
|
228
|
+
occurrences: 5,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const patternsSection = briefing.sections.find(s => s.title === 'Observed Patterns');
|
|
234
|
+
expect(patternsSection).toBeDefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should filter dismissed notifications', () => {
|
|
238
|
+
const briefing = generator.generateBriefing('user1', 'morning', {
|
|
239
|
+
notifications: [
|
|
240
|
+
{ id: 'n1', priority: 'nudge', message: 'Old', createdAt: Date.now(), dismissed: true, source: 'test' },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const notifSection = briefing.sections.find(s => s.title === 'Notifications');
|
|
245
|
+
expect(notifSection).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should respect maxItemsPerSection', () => {
|
|
249
|
+
const generator2 = new BriefingGenerator({ maxItemsPerSection: 2 });
|
|
250
|
+
const briefing = generator2.generateBriefing('user1', 'morning', {
|
|
251
|
+
calendarEvents: [
|
|
252
|
+
{ title: 'Event 1', time: '09:00' },
|
|
253
|
+
{ title: 'Event 2', time: '10:00' },
|
|
254
|
+
{ title: 'Event 3', time: '11:00' },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const calSection = briefing.sections.find(s => s.title.includes('Schedule'));
|
|
259
|
+
expect(calSection!.items).toHaveLength(2);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should return config', () => {
|
|
263
|
+
const config = generator.getConfig();
|
|
264
|
+
expect(config.enabled).toBe(true);
|
|
265
|
+
expect(config.morningTime).toBe('08:00');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('QuietNotificationManager', () => {
|
|
270
|
+
let manager: QuietNotificationManager;
|
|
271
|
+
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
manager = new QuietNotificationManager();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should create notifications', () => {
|
|
277
|
+
const n = manager.notify('nudge', 'Test message');
|
|
278
|
+
expect(n.id).toBeDefined();
|
|
279
|
+
expect(n.priority).toBe('nudge');
|
|
280
|
+
expect(n.message).toBe('Test message');
|
|
281
|
+
expect(n.dismissed).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should queue notifications by priority', () => {
|
|
285
|
+
manager.notify('whisper', 'Low');
|
|
286
|
+
manager.notify('alert', 'High');
|
|
287
|
+
manager.notify('nudge', 'Medium');
|
|
288
|
+
|
|
289
|
+
const queue = manager.getQueue();
|
|
290
|
+
expect(queue[0].priority).toBe('alert');
|
|
291
|
+
expect(queue[1].priority).toBe('nudge');
|
|
292
|
+
expect(queue[2].priority).toBe('whisper');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should dismiss a notification', () => {
|
|
296
|
+
const n = manager.notify('nudge', 'Dismiss me');
|
|
297
|
+
expect(manager.dismiss(n.id)).toBe(true);
|
|
298
|
+
expect(manager.getQueue()).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should return false for unknown dismiss', () => {
|
|
302
|
+
expect(manager.dismiss('nonexistent')).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should dismiss all', () => {
|
|
306
|
+
manager.notify('whisper', 'A');
|
|
307
|
+
manager.notify('nudge', 'B');
|
|
308
|
+
const count = manager.dismissAll();
|
|
309
|
+
expect(count).toBe(2);
|
|
310
|
+
expect(manager.getQueue()).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should filter by priority', () => {
|
|
314
|
+
manager.notify('whisper', 'A');
|
|
315
|
+
manager.notify('alert', 'B');
|
|
316
|
+
manager.notify('whisper', 'C');
|
|
317
|
+
|
|
318
|
+
const whispers = manager.getByPriority('whisper');
|
|
319
|
+
expect(whispers).toHaveLength(2);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should get notification by ID', () => {
|
|
323
|
+
const n = manager.notify('alert', 'Important');
|
|
324
|
+
const fetched = manager.get(n.id);
|
|
325
|
+
expect(fetched).toBeDefined();
|
|
326
|
+
expect(fetched!.message).toBe('Important');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should count pending notifications', () => {
|
|
330
|
+
manager.notify('whisper', 'A');
|
|
331
|
+
manager.notify('nudge', 'B');
|
|
332
|
+
expect(manager.getPendingCount()).toBe(2);
|
|
333
|
+
|
|
334
|
+
const n = manager.notify('alert', 'C');
|
|
335
|
+
manager.dismiss(n.id);
|
|
336
|
+
expect(manager.getPendingCount()).toBe(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should prune old dismissed notifications', () => {
|
|
340
|
+
const n = manager.notify('whisper', 'Old');
|
|
341
|
+
manager.dismiss(n.id);
|
|
342
|
+
// Manually set createdAt to old
|
|
343
|
+
const notification = manager.get(n.id)!;
|
|
344
|
+
(notification as any).createdAt = Date.now() - 48 * 60 * 60 * 1000;
|
|
345
|
+
|
|
346
|
+
const pruned = manager.prune();
|
|
347
|
+
expect(pruned).toBe(1);
|
|
348
|
+
expect(manager.get(n.id)).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should clear all', () => {
|
|
352
|
+
manager.notify('alert', 'A');
|
|
353
|
+
manager.notify('nudge', 'B');
|
|
354
|
+
manager.clear();
|
|
355
|
+
expect(manager.getPendingCount()).toBe(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should include source and detail', () => {
|
|
359
|
+
const n = manager.notify('nudge', 'Msg', { source: 'calendar', detail: 'Extra info' });
|
|
360
|
+
expect(n.source).toBe('calendar');
|
|
361
|
+
expect(n.detail).toBe('Extra info');
|
|
362
|
+
});
|
|
363
|
+
});
|