@vellumai/assistant 0.3.8 → 0.3.9
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 +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +84 -1
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -0
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +10 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +31 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Detects notification-related user statements in conversation messages
|
|
5
|
+
* and extracts structured preference data. Uses a small/fast model
|
|
6
|
+
* (haiku) with a focused prompt for lightweight detection + extraction.
|
|
7
|
+
*
|
|
8
|
+
* Examples of statements it should detect:
|
|
9
|
+
* - "Use Telegram for urgent alerts"
|
|
10
|
+
* - "Don't notify me on desktop during work calls"
|
|
11
|
+
* - "Weeknights after 10pm: only critical notifications"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getConfig } from '../config/loader.js';
|
|
15
|
+
import { getLogger } from '../util/logger.js';
|
|
16
|
+
import { getConfiguredProvider, createTimeout, extractToolUse, userMessage } from '../providers/provider-send-message.js';
|
|
17
|
+
import type { AppliesWhenConditions } from './preferences-store.js';
|
|
18
|
+
|
|
19
|
+
const log = getLogger('notification-preference-extractor');
|
|
20
|
+
|
|
21
|
+
const EXTRACTION_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
// ── Extraction result ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ExtractedPreference {
|
|
26
|
+
preferenceText: string;
|
|
27
|
+
appliesWhen: AppliesWhenConditions;
|
|
28
|
+
priority: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExtractionResult {
|
|
32
|
+
detected: boolean;
|
|
33
|
+
preferences: ExtractedPreference[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── System prompt ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const SYSTEM_PROMPT = `You are a notification preference detector. Given a user message from a conversation, determine if it contains any notification preferences or routing instructions.
|
|
39
|
+
|
|
40
|
+
Notification preferences are statements about HOW, WHEN, or WHERE the user wants to receive notifications. Examples:
|
|
41
|
+
- "Use Telegram for urgent alerts"
|
|
42
|
+
- "Don't notify me on desktop during work calls"
|
|
43
|
+
- "Weeknights after 10pm: only critical notifications"
|
|
44
|
+
- "Send me everything on macOS"
|
|
45
|
+
- "Only bug me for high priority stuff"
|
|
46
|
+
- "Mute notifications between 11pm and 7am"
|
|
47
|
+
- "I prefer Telegram over desktop notifications"
|
|
48
|
+
|
|
49
|
+
If the message does NOT contain any notification preferences, respond with the tool setting detected=false and an empty preferences array.
|
|
50
|
+
|
|
51
|
+
If it DOES contain preferences, extract each one as a separate entry with:
|
|
52
|
+
- preferenceText: the natural language preference as stated
|
|
53
|
+
- appliesWhen: structured conditions (timeRange, channels, urgencyLevels, contexts)
|
|
54
|
+
- priority: 0 for general defaults, 1 for specific overrides, 2 for critical/urgent overrides
|
|
55
|
+
|
|
56
|
+
You MUST respond using the \`extract_notification_preferences\` tool.`;
|
|
57
|
+
|
|
58
|
+
// ── Tool definition ────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const EXTRACTION_TOOL = {
|
|
61
|
+
name: 'extract_notification_preferences',
|
|
62
|
+
description: 'Extract notification preferences from a user message',
|
|
63
|
+
input_schema: {
|
|
64
|
+
type: 'object' as const,
|
|
65
|
+
properties: {
|
|
66
|
+
detected: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Whether the message contains notification preferences',
|
|
69
|
+
},
|
|
70
|
+
preferences: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
description: 'Array of extracted preferences (empty if detected=false)',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
preferenceText: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'The natural language preference as stated by the user',
|
|
79
|
+
},
|
|
80
|
+
appliesWhen: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
description: 'Structured conditions for when this preference applies',
|
|
83
|
+
properties: {
|
|
84
|
+
timeRange: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
after: { type: 'string', description: 'Start time in HH:MM format' },
|
|
88
|
+
before: { type: 'string', description: 'End time in HH:MM format' },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
channels: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
items: { type: 'string' },
|
|
94
|
+
description: 'Channels this preference applies to (e.g. telegram, macos)',
|
|
95
|
+
},
|
|
96
|
+
urgencyLevels: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
items: { type: 'string' },
|
|
99
|
+
description: 'Urgency levels this preference applies to (e.g. low, medium, high, critical)',
|
|
100
|
+
},
|
|
101
|
+
contexts: {
|
|
102
|
+
type: 'array',
|
|
103
|
+
items: { type: 'string' },
|
|
104
|
+
description: 'Situational contexts (e.g. work_calls, meetings, sleeping)',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
priority: {
|
|
109
|
+
type: 'number',
|
|
110
|
+
description: 'Priority for conflict resolution: 0=general default, 1=specific override, 2=critical override',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ['preferenceText', 'appliesWhen', 'priority'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
required: ['detected', 'preferences'],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Core extraction function ───────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export async function extractPreferences(message: string): Promise<ExtractionResult> {
|
|
124
|
+
const provider = getConfiguredProvider();
|
|
125
|
+
if (!provider) {
|
|
126
|
+
log.debug('No provider available for preference extraction');
|
|
127
|
+
return { detected: false, preferences: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const config = getConfig();
|
|
131
|
+
const model = config.notifications.decisionModel;
|
|
132
|
+
|
|
133
|
+
const { signal, cleanup } = createTimeout(EXTRACTION_TIMEOUT_MS);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await provider.sendMessage(
|
|
137
|
+
[userMessage(message)],
|
|
138
|
+
[EXTRACTION_TOOL],
|
|
139
|
+
SYSTEM_PROMPT,
|
|
140
|
+
{
|
|
141
|
+
config: {
|
|
142
|
+
model,
|
|
143
|
+
max_tokens: 1024,
|
|
144
|
+
tool_choice: { type: 'tool' as const, name: 'extract_notification_preferences' },
|
|
145
|
+
},
|
|
146
|
+
signal,
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
cleanup();
|
|
150
|
+
|
|
151
|
+
const toolBlock = extractToolUse(response);
|
|
152
|
+
if (!toolBlock) {
|
|
153
|
+
log.debug('No tool_use block in preference extraction response');
|
|
154
|
+
return { detected: false, preferences: [] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const input = toolBlock.input as Record<string, unknown>;
|
|
158
|
+
return validateExtractionOutput(input);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
log.debug({ err: errMsg }, 'Preference extraction failed');
|
|
162
|
+
return { detected: false, preferences: [] };
|
|
163
|
+
} finally {
|
|
164
|
+
cleanup();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Validation ─────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function validateExtractionOutput(input: Record<string, unknown>): ExtractionResult {
|
|
171
|
+
if (typeof input.detected !== 'boolean') {
|
|
172
|
+
return { detected: false, preferences: [] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!input.detected || !Array.isArray(input.preferences)) {
|
|
176
|
+
return { detected: false, preferences: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const preferences: ExtractedPreference[] = [];
|
|
180
|
+
|
|
181
|
+
for (const raw of input.preferences) {
|
|
182
|
+
if (typeof raw !== 'object' || raw === null) continue;
|
|
183
|
+
const p = raw as Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
if (typeof p.preferenceText !== 'string' || !p.preferenceText.trim()) continue;
|
|
186
|
+
|
|
187
|
+
const appliesWhen: AppliesWhenConditions = {};
|
|
188
|
+
if (p.appliesWhen && typeof p.appliesWhen === 'object') {
|
|
189
|
+
const aw = p.appliesWhen as Record<string, unknown>;
|
|
190
|
+
if (aw.timeRange && typeof aw.timeRange === 'object') {
|
|
191
|
+
const tr = aw.timeRange as Record<string, unknown>;
|
|
192
|
+
appliesWhen.timeRange = {
|
|
193
|
+
after: typeof tr.after === 'string' ? tr.after : undefined,
|
|
194
|
+
before: typeof tr.before === 'string' ? tr.before : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(aw.channels)) {
|
|
198
|
+
appliesWhen.channels = aw.channels.filter((c): c is string => typeof c === 'string');
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(aw.urgencyLevels)) {
|
|
201
|
+
appliesWhen.urgencyLevels = aw.urgencyLevels.filter((u): u is string => typeof u === 'string');
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(aw.contexts)) {
|
|
204
|
+
appliesWhen.contexts = aw.contexts.filter((c): c is string => typeof c === 'string');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const priority = typeof p.priority === 'number'
|
|
209
|
+
? Math.max(0, Math.min(2, Math.round(p.priority)))
|
|
210
|
+
: 0;
|
|
211
|
+
|
|
212
|
+
preferences.push({
|
|
213
|
+
preferenceText: p.preferenceText.trim(),
|
|
214
|
+
appliesWhen,
|
|
215
|
+
priority,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
detected: preferences.length > 0,
|
|
221
|
+
preferences,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference summary retriever.
|
|
3
|
+
*
|
|
4
|
+
* Builds a compact "notification preference summary" string for inclusion
|
|
5
|
+
* in the decision engine's system prompt. Fetches all stored preferences
|
|
6
|
+
* for an assistant and merges them into a coherent block that the LLM
|
|
7
|
+
* can interpret when making routing decisions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
import { listPreferences } from './preferences-store.js';
|
|
12
|
+
import type { AppliesWhenConditions } from './preferences-store.js';
|
|
13
|
+
|
|
14
|
+
const log = getLogger('notification-preference-summary');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a compact preference summary for inclusion in the decision engine
|
|
18
|
+
* system prompt. Returns null if no preferences are stored.
|
|
19
|
+
*/
|
|
20
|
+
export function getPreferenceSummary(assistantId: string): string | null {
|
|
21
|
+
const preferences = listPreferences(assistantId);
|
|
22
|
+
|
|
23
|
+
if (preferences.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
'The user has set the following notification preferences (ordered by priority, highest first):',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const pref of preferences) {
|
|
32
|
+
const safeText = sanitizePreferenceText(pref.preferenceText);
|
|
33
|
+
const conditionStr = formatConditions(pref.appliesWhenJson);
|
|
34
|
+
const priorityLabel = pref.priority >= 2 ? 'CRITICAL' : pref.priority === 1 ? 'override' : 'default';
|
|
35
|
+
const prefix = `[${priorityLabel}]`;
|
|
36
|
+
|
|
37
|
+
if (conditionStr) {
|
|
38
|
+
lines.push(`${prefix} "${safeText}" (when: ${conditionStr})`);
|
|
39
|
+
} else {
|
|
40
|
+
lines.push(`${prefix} "${safeText}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
log.debug({ count: preferences.length }, 'Built preference summary');
|
|
45
|
+
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Text sanitization ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip XML/HTML-like tags from preference text to prevent prompt injection.
|
|
53
|
+
* Replaces angle brackets with harmless unicode equivalents so user-authored
|
|
54
|
+
* text cannot break the `<user-preferences>` framing in the system prompt.
|
|
55
|
+
*/
|
|
56
|
+
function sanitizePreferenceText(text: string): string {
|
|
57
|
+
return text.replace(/</g, '\uFF1C').replace(/>/g, '\uFF1E');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Safely convert and sanitize a value to a string.
|
|
62
|
+
* Coerces to string first (if needed) then sanitizes with sanitizePreferenceText.
|
|
63
|
+
* This ensures all values are sanitized regardless of their original type,
|
|
64
|
+
* preventing prompt injection via coerced types.
|
|
65
|
+
*/
|
|
66
|
+
function safeString(value: unknown): string {
|
|
67
|
+
return sanitizePreferenceText(typeof value === 'string' ? value : String(value ?? ''));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Condition formatting ────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function formatConditions(appliesWhenJson: string): string {
|
|
73
|
+
let conditions: AppliesWhenConditions;
|
|
74
|
+
try {
|
|
75
|
+
conditions = JSON.parse(appliesWhenJson);
|
|
76
|
+
} catch {
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Skip empty condition objects
|
|
81
|
+
if (!conditions || typeof conditions !== 'object') return '';
|
|
82
|
+
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
|
|
85
|
+
if (conditions.timeRange) {
|
|
86
|
+
const after = conditions.timeRange.after ? safeString(conditions.timeRange.after) : '';
|
|
87
|
+
const before = conditions.timeRange.before ? safeString(conditions.timeRange.before) : '';
|
|
88
|
+
if (after && before) {
|
|
89
|
+
parts.push(`${after}-${before}`);
|
|
90
|
+
} else if (after) {
|
|
91
|
+
parts.push(`after ${after}`);
|
|
92
|
+
} else if (before) {
|
|
93
|
+
parts.push(`before ${before}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (conditions.channels && conditions.channels.length > 0) {
|
|
98
|
+
parts.push(`channels: ${conditions.channels.map(safeString).join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (conditions.urgencyLevels && conditions.urgencyLevels.length > 0) {
|
|
102
|
+
parts.push(`urgency: ${conditions.urgencyLevels.map(safeString).join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (conditions.contexts && conditions.contexts.length > 0) {
|
|
106
|
+
parts.push(`context: ${conditions.contexts.map(safeString).join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join('; ');
|
|
110
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD operations for notification preferences.
|
|
3
|
+
*
|
|
4
|
+
* Each row stores a natural-language notification preference expressed by
|
|
5
|
+
* the user (e.g. "Use Telegram for urgent alerts"), along with structured
|
|
6
|
+
* conditions for when the preference applies and a priority for conflict
|
|
7
|
+
* resolution.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
11
|
+
import { v4 as uuid } from 'uuid';
|
|
12
|
+
import { getDb } from '../memory/db.js';
|
|
13
|
+
import { notificationPreferences } from '../memory/schema.js';
|
|
14
|
+
|
|
15
|
+
// ── Row type ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface NotificationPreferenceRow {
|
|
18
|
+
id: string;
|
|
19
|
+
assistantId: string;
|
|
20
|
+
preferenceText: string;
|
|
21
|
+
appliesWhenJson: string; // serialised JSON
|
|
22
|
+
priority: number;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rowToPreference(row: typeof notificationPreferences.$inferSelect): NotificationPreferenceRow {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
assistantId: row.assistantId,
|
|
31
|
+
preferenceText: row.preferenceText,
|
|
32
|
+
appliesWhenJson: row.appliesWhenJson,
|
|
33
|
+
priority: row.priority,
|
|
34
|
+
createdAt: row.createdAt,
|
|
35
|
+
updatedAt: row.updatedAt,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Structured conditions type ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface AppliesWhenConditions {
|
|
42
|
+
timeRange?: { after?: string; before?: string }; // e.g. "22:00", "06:00"
|
|
43
|
+
channels?: string[]; // e.g. ["telegram", "macos"]
|
|
44
|
+
urgencyLevels?: string[]; // e.g. ["high", "critical"]
|
|
45
|
+
contexts?: string[]; // e.g. ["work_calls", "meetings"]
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Create ──────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface CreatePreferenceParams {
|
|
52
|
+
assistantId: string;
|
|
53
|
+
preferenceText: string;
|
|
54
|
+
appliesWhen?: AppliesWhenConditions;
|
|
55
|
+
priority?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createPreference(params: CreatePreferenceParams): NotificationPreferenceRow {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
|
|
62
|
+
const row = {
|
|
63
|
+
id: uuid(),
|
|
64
|
+
assistantId: params.assistantId,
|
|
65
|
+
preferenceText: params.preferenceText,
|
|
66
|
+
appliesWhenJson: JSON.stringify(params.appliesWhen ?? {}),
|
|
67
|
+
priority: params.priority ?? 0,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
db.insert(notificationPreferences).values(row).run();
|
|
73
|
+
|
|
74
|
+
return row;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── List ────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function listPreferences(assistantId: string): NotificationPreferenceRow[] {
|
|
80
|
+
const db = getDb();
|
|
81
|
+
|
|
82
|
+
const rows = db
|
|
83
|
+
.select()
|
|
84
|
+
.from(notificationPreferences)
|
|
85
|
+
.where(eq(notificationPreferences.assistantId, assistantId))
|
|
86
|
+
.orderBy(desc(notificationPreferences.priority))
|
|
87
|
+
.all();
|
|
88
|
+
|
|
89
|
+
return rows.map(rowToPreference);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Update ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface UpdatePreferenceParams {
|
|
95
|
+
preferenceText?: string;
|
|
96
|
+
appliesWhen?: AppliesWhenConditions;
|
|
97
|
+
priority?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function updatePreference(id: string, params: UpdatePreferenceParams): boolean {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
|
|
104
|
+
const updates: Record<string, unknown> = { updatedAt: now };
|
|
105
|
+
if (params.preferenceText !== undefined) updates.preferenceText = params.preferenceText;
|
|
106
|
+
if (params.appliesWhen !== undefined) updates.appliesWhenJson = JSON.stringify(params.appliesWhen);
|
|
107
|
+
if (params.priority !== undefined) updates.priority = params.priority;
|
|
108
|
+
|
|
109
|
+
const result = db
|
|
110
|
+
.update(notificationPreferences)
|
|
111
|
+
.set(updates)
|
|
112
|
+
.where(eq(notificationPreferences.id, id))
|
|
113
|
+
.run() as unknown as { changes?: number };
|
|
114
|
+
|
|
115
|
+
return (result.changes ?? 0) > 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Delete ──────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export function deletePreference(id: string): boolean {
|
|
121
|
+
const db = getDb();
|
|
122
|
+
|
|
123
|
+
const result = db
|
|
124
|
+
.delete(notificationPreferences)
|
|
125
|
+
.where(eq(notificationPreferences.id, id))
|
|
126
|
+
.run() as unknown as { changes?: number };
|
|
127
|
+
|
|
128
|
+
return (result.changes ?? 0) > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Get by ID ───────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export function getPreferenceById(id: string): NotificationPreferenceRow | null {
|
|
134
|
+
const db = getDb();
|
|
135
|
+
const row = db
|
|
136
|
+
.select()
|
|
137
|
+
.from(notificationPreferences)
|
|
138
|
+
.where(eq(notificationPreferences.id, id))
|
|
139
|
+
.get();
|
|
140
|
+
if (!row) return null;
|
|
141
|
+
return rowToPreference(row);
|
|
142
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-loop dispatch helper that wires the decision engine output to the
|
|
3
|
+
* broadcaster/adapters for end-to-end signal → decision → dispatch → delivery.
|
|
4
|
+
*
|
|
5
|
+
* Not a standalone service — called inline from the notification processing
|
|
6
|
+
* loop after the decision engine and deterministic checks have run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getConfig } from '../config/loader.js';
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
import type { NotificationBroadcaster } from './broadcaster.js';
|
|
12
|
+
import type { NotificationSignal } from './signal.js';
|
|
13
|
+
import type { NotificationDecision, NotificationDeliveryResult } from './types.js';
|
|
14
|
+
|
|
15
|
+
const log = getLogger('notification-dispatch');
|
|
16
|
+
|
|
17
|
+
export interface DispatchResult {
|
|
18
|
+
dispatched: boolean;
|
|
19
|
+
reason: string;
|
|
20
|
+
deliveryResults: NotificationDeliveryResult[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dispatch a notification decision through the broadcaster.
|
|
25
|
+
*
|
|
26
|
+
* Handles three early-exit cases before delegating to the broadcaster:
|
|
27
|
+
* 1. shouldNotify === false — the decision says not to notify
|
|
28
|
+
* 2. Shadow mode — decisions are logged but not actually dispatched
|
|
29
|
+
* 3. No selected channels — nothing to dispatch
|
|
30
|
+
*/
|
|
31
|
+
export async function dispatchDecision(
|
|
32
|
+
signal: NotificationSignal,
|
|
33
|
+
decision: NotificationDecision,
|
|
34
|
+
broadcaster: NotificationBroadcaster,
|
|
35
|
+
): Promise<DispatchResult> {
|
|
36
|
+
// No-op when the decision engine says not to notify
|
|
37
|
+
if (!decision.shouldNotify) {
|
|
38
|
+
log.info(
|
|
39
|
+
{ signalId: signal.signalId, reason: decision.reasoningSummary },
|
|
40
|
+
'Decision: do not notify',
|
|
41
|
+
);
|
|
42
|
+
return {
|
|
43
|
+
dispatched: false,
|
|
44
|
+
reason: 'Decision: shouldNotify=false',
|
|
45
|
+
deliveryResults: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Shadow mode: log the decision but skip actual delivery
|
|
50
|
+
const config = getConfig();
|
|
51
|
+
if (config.notifications.shadowMode) {
|
|
52
|
+
log.info(
|
|
53
|
+
{
|
|
54
|
+
signalId: signal.signalId,
|
|
55
|
+
channels: decision.selectedChannels,
|
|
56
|
+
confidence: decision.confidence,
|
|
57
|
+
fallbackUsed: decision.fallbackUsed,
|
|
58
|
+
},
|
|
59
|
+
'Shadow mode: skipping dispatch (decision logged only)',
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
dispatched: false,
|
|
63
|
+
reason: 'Shadow mode enabled — dispatch skipped',
|
|
64
|
+
deliveryResults: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Guard against empty channel list
|
|
69
|
+
if (decision.selectedChannels.length === 0) {
|
|
70
|
+
log.info(
|
|
71
|
+
{ signalId: signal.signalId },
|
|
72
|
+
'No channels selected in decision — nothing to dispatch',
|
|
73
|
+
);
|
|
74
|
+
return {
|
|
75
|
+
dispatched: false,
|
|
76
|
+
reason: 'No channels selected',
|
|
77
|
+
deliveryResults: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Dispatch through the broadcaster
|
|
82
|
+
const deliveryResults = await broadcaster.broadcastDecision(signal, decision);
|
|
83
|
+
|
|
84
|
+
const sentCount = deliveryResults.filter((r) => r.status === 'sent').length;
|
|
85
|
+
log.info(
|
|
86
|
+
{
|
|
87
|
+
signalId: signal.signalId,
|
|
88
|
+
channels: decision.selectedChannels,
|
|
89
|
+
sentCount,
|
|
90
|
+
totalAttempted: deliveryResults.length,
|
|
91
|
+
},
|
|
92
|
+
'Dispatch complete',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
dispatched: true,
|
|
97
|
+
reason: `Dispatched to ${sentCount}/${deliveryResults.length} channels`,
|
|
98
|
+
deliveryResults,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationSignal -- the flexible input from producers.
|
|
3
|
+
* Uses free-form event names and structured attention hints that let the
|
|
4
|
+
* decision engine route contextually.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface AttentionHints {
|
|
8
|
+
requiresAction: boolean;
|
|
9
|
+
urgency: 'low' | 'medium' | 'high';
|
|
10
|
+
deadlineAt?: number; // epoch ms
|
|
11
|
+
isAsyncBackground: boolean;
|
|
12
|
+
visibleInSourceNow: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotificationSignal {
|
|
16
|
+
signalId: string;
|
|
17
|
+
assistantId: string;
|
|
18
|
+
createdAt: number; // epoch ms
|
|
19
|
+
sourceChannel: string; // free-form: 'macos', 'telegram', 'voice', 'scheduler', etc.
|
|
20
|
+
sourceSessionId: string;
|
|
21
|
+
sourceEventName: string; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
|
|
22
|
+
contextPayload: Record<string, unknown>;
|
|
23
|
+
attentionHints: AttentionHints;
|
|
24
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core domain types for the unified notification system.
|
|
3
|
+
*
|
|
4
|
+
* Defines the channel-adapter interfaces that the broadcaster and adapters
|
|
5
|
+
* depend on, plus the decision engine output contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type NotificationChannel = 'macos' | 'telegram';
|
|
9
|
+
|
|
10
|
+
export type NotificationDeliveryStatus = 'pending' | 'sent' | 'failed' | 'skipped';
|
|
11
|
+
|
|
12
|
+
/** Result of attempting to deliver a notification to a single channel. */
|
|
13
|
+
export interface NotificationDeliveryResult {
|
|
14
|
+
channel: NotificationChannel;
|
|
15
|
+
destination: string;
|
|
16
|
+
status: NotificationDeliveryStatus;
|
|
17
|
+
errorCode?: string;
|
|
18
|
+
errorMessage?: string;
|
|
19
|
+
sentAt?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// -- Channel adapter interfaces -----------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Result returned by a channel adapter after attempting to send. */
|
|
25
|
+
export interface DeliveryResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolved destination for a specific channel. */
|
|
31
|
+
export interface ChannelDestination {
|
|
32
|
+
channel: NotificationChannel;
|
|
33
|
+
endpoint?: string;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Delivery payload assembled from the decision engine's rendered copy
|
|
39
|
+
* plus contextual fields the adapters need for formatting and routing.
|
|
40
|
+
*/
|
|
41
|
+
export interface ChannelDeliveryPayload {
|
|
42
|
+
sourceEventName: string;
|
|
43
|
+
copy: RenderedChannelCopy;
|
|
44
|
+
deepLinkTarget?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Interface that each channel adapter must implement. */
|
|
48
|
+
export interface ChannelAdapter {
|
|
49
|
+
channel: NotificationChannel;
|
|
50
|
+
send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// -- Decision engine output ---------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Rendered notification copy for a single channel. */
|
|
56
|
+
export interface RenderedChannelCopy {
|
|
57
|
+
title: string;
|
|
58
|
+
body: string;
|
|
59
|
+
threadTitle?: string;
|
|
60
|
+
threadSeedMessage?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Output produced by the notification decision engine for a given signal. */
|
|
64
|
+
export interface NotificationDecision {
|
|
65
|
+
shouldNotify: boolean;
|
|
66
|
+
selectedChannels: NotificationChannel[];
|
|
67
|
+
reasoningSummary: string;
|
|
68
|
+
renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>>;
|
|
69
|
+
deepLinkTarget?: Record<string, unknown>;
|
|
70
|
+
dedupeKey: string;
|
|
71
|
+
confidence: number;
|
|
72
|
+
fallbackUsed: boolean;
|
|
73
|
+
/** UUID of the persisted decision row (set after persistence in the decision engine). */
|
|
74
|
+
persistedDecisionId?: string;
|
|
75
|
+
}
|