@vellumai/assistant 0.3.7 → 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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -0,0 +1,175 @@
1
+ /**
2
+ * NotificationBroadcaster -- dispatches a notification decision to all
3
+ * selected channels through their respective adapters.
4
+ *
5
+ * For each channel in the decision's selectedChannels:
6
+ * 1. Resolves the destination via the destination-resolver
7
+ * 2. Pulls rendered copy from the decision (or falls back to copy-composer)
8
+ * 3. Dispatches through the channel adapter
9
+ * 4. Records a delivery audit row in the deliveries-store
10
+ */
11
+
12
+ import { v4 as uuid } from 'uuid';
13
+ import { getLogger } from '../util/logger.js';
14
+ import { composeFallbackCopy } from './copy-composer.js';
15
+ import { resolveDestinations } from './destination-resolver.js';
16
+ import { createDelivery, updateDeliveryStatus } from './deliveries-store.js';
17
+ import type { NotificationSignal } from './signal.js';
18
+ import type {
19
+ NotificationChannel,
20
+ NotificationDecision,
21
+ NotificationDeliveryResult,
22
+ ChannelAdapter,
23
+ ChannelDeliveryPayload,
24
+ RenderedChannelCopy,
25
+ } from './types.js';
26
+
27
+ const log = getLogger('notif-broadcaster');
28
+
29
+ export class NotificationBroadcaster {
30
+ private adapters: Map<NotificationChannel, ChannelAdapter>;
31
+
32
+ constructor(adapters: ChannelAdapter[]) {
33
+ this.adapters = new Map();
34
+ for (const adapter of adapters) {
35
+ this.adapters.set(adapter.channel, adapter);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Broadcast a notification decision to all selected channels.
41
+ *
42
+ * The decision carries rendered copy per channel. When the decision was
43
+ * produced by the fallback path (fallbackUsed === true) and is missing
44
+ * copy for a channel, the copy-composer generates deterministic fallback copy.
45
+ *
46
+ * Returns an array of delivery results -- one per channel attempted.
47
+ */
48
+ async broadcastDecision(
49
+ signal: NotificationSignal,
50
+ decision: NotificationDecision,
51
+ ): Promise<NotificationDeliveryResult[]> {
52
+ const destinations = resolveDestinations(signal.assistantId, decision.selectedChannels);
53
+
54
+ // Pre-compute fallback copy in case any channel is missing rendered copy
55
+ let fallbackCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> | null = null;
56
+
57
+ const results: NotificationDeliveryResult[] = [];
58
+
59
+ for (const channel of decision.selectedChannels) {
60
+ const adapter = this.adapters.get(channel);
61
+ if (!adapter) {
62
+ log.warn({ channel, signalId: signal.signalId }, 'No adapter registered for channel -- skipping');
63
+ results.push({
64
+ channel,
65
+ destination: '',
66
+ status: 'skipped',
67
+ errorMessage: `No adapter for channel: ${channel}`,
68
+ });
69
+ continue;
70
+ }
71
+
72
+ const destination = destinations.get(channel);
73
+ if (!destination) {
74
+ log.warn({ channel, signalId: signal.signalId }, 'Could not resolve destination -- skipping');
75
+ results.push({
76
+ channel,
77
+ destination: '',
78
+ status: 'skipped',
79
+ errorMessage: `Destination not resolved for channel: ${channel}`,
80
+ });
81
+ continue;
82
+ }
83
+
84
+ // Pull rendered copy from the decision; fall back to copy-composer if missing
85
+ let copy = decision.renderedCopy[channel];
86
+ if (!copy) {
87
+ if (!fallbackCopy) {
88
+ fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
89
+ }
90
+ copy = fallbackCopy[channel] ?? { title: 'Notification', body: signal.sourceEventName };
91
+ }
92
+
93
+ const payload: ChannelDeliveryPayload = {
94
+ sourceEventName: signal.sourceEventName,
95
+ copy,
96
+ deepLinkTarget: decision.deepLinkTarget,
97
+ };
98
+
99
+ const deliveryId = uuid();
100
+ const destinationLabel = destination.endpoint ?? channel;
101
+
102
+ // Only create a delivery audit record when we have a persisted decision ID
103
+ // for the FK. If decision persistence failed (persistedDecisionId is
104
+ // undefined), we still dispatch via the adapter but skip the delivery
105
+ // record — using dedupeKey would violate the FK constraint.
106
+ const persistedDecisionId = decision.persistedDecisionId;
107
+ const hasPersistedDecision = typeof persistedDecisionId === 'string';
108
+
109
+ try {
110
+ if (hasPersistedDecision) {
111
+ createDelivery({
112
+ id: deliveryId,
113
+ notificationDecisionId: persistedDecisionId,
114
+ assistantId: signal.assistantId,
115
+ channel,
116
+ destination: destinationLabel,
117
+ status: 'pending',
118
+ attempt: 1,
119
+ renderedTitle: copy.title,
120
+ renderedBody: copy.body,
121
+ });
122
+ } else {
123
+ log.warn(
124
+ { channel, signalId: signal.signalId },
125
+ 'No persisted decision ID -- skipping delivery record creation',
126
+ );
127
+ }
128
+
129
+ const adapterResult = await adapter.send(payload, destination);
130
+
131
+ if (adapterResult.success) {
132
+ if (hasPersistedDecision) {
133
+ updateDeliveryStatus(deliveryId, 'sent');
134
+ }
135
+ results.push({
136
+ channel,
137
+ destination: destinationLabel,
138
+ status: 'sent',
139
+ sentAt: Date.now(),
140
+ });
141
+ } else {
142
+ if (hasPersistedDecision) {
143
+ updateDeliveryStatus(deliveryId, 'failed', { message: adapterResult.error });
144
+ }
145
+ results.push({
146
+ channel,
147
+ destination: destinationLabel,
148
+ status: 'failed',
149
+ errorMessage: adapterResult.error,
150
+ });
151
+ }
152
+ } catch (err) {
153
+ const errorMessage = err instanceof Error ? err.message : String(err);
154
+ log.error({ err, channel, signalId: signal.signalId }, 'Unexpected error during channel delivery');
155
+
156
+ if (hasPersistedDecision) {
157
+ try {
158
+ updateDeliveryStatus(deliveryId, 'failed', { message: errorMessage });
159
+ } catch {
160
+ // Swallow -- the delivery record may not exist if createDelivery failed
161
+ }
162
+ }
163
+
164
+ results.push({
165
+ channel,
166
+ destination: destinationLabel,
167
+ status: 'failed',
168
+ errorMessage,
169
+ });
170
+ }
171
+ }
172
+
173
+ return results;
174
+ }
175
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Deterministic, template-based copy generation for notification deliveries.
3
+ *
4
+ * This is the fallback path used when the decision engine's LLM-generated
5
+ * copy is unavailable (fallbackUsed === true). It generates reasonable
6
+ * copy from the signal's sourceEventName, contextPayload, and attentionHints.
7
+ *
8
+ * Each source event name has a set of fallback templates that interpolate
9
+ * values from the context payload.
10
+ */
11
+
12
+ import type { NotificationSignal } from './signal.js';
13
+ import type { NotificationChannel, RenderedChannelCopy } from './types.js';
14
+
15
+ type CopyTemplate = (payload: Record<string, unknown>) => RenderedChannelCopy;
16
+
17
+ function str(value: unknown, fallback: string): string {
18
+ if (typeof value === 'string' && value.length > 0) return value;
19
+ return fallback;
20
+ }
21
+
22
+ // Templates keyed by dot-separated sourceEventName strings matching producers.
23
+ const TEMPLATES: Record<string, CopyTemplate> = {
24
+ 'reminder.fired': (payload) => ({
25
+ title: 'Reminder',
26
+ body: str(payload.message, str(payload.label, 'A reminder has fired')),
27
+ }),
28
+
29
+ 'schedule.complete': (payload) => ({
30
+ title: 'Schedule Complete',
31
+ body: `${str(payload.name, 'A schedule')} has finished running`,
32
+ }),
33
+
34
+ 'guardian.question': (payload) => ({
35
+ title: 'Guardian Question',
36
+ body: str(payload.questionText, 'A guardian question needs your attention'),
37
+ }),
38
+
39
+ 'ingress.escalation': (payload) => ({
40
+ title: 'Escalation',
41
+ body: str(payload.senderIdentifier, 'An incoming message') + ' needs attention',
42
+ }),
43
+
44
+ 'watcher.notification': (payload) => ({
45
+ title: str(payload.title, 'Watcher Notification'),
46
+ body: str(payload.body, 'A watcher event occurred'),
47
+ }),
48
+
49
+ 'watcher.escalation': (payload) => ({
50
+ title: str(payload.title, 'Watcher Escalation'),
51
+ body: str(payload.body, 'A watcher event requires your attention'),
52
+ }),
53
+
54
+ 'tool_confirmation.required_action': (payload) => ({
55
+ title: 'Tool Confirmation',
56
+ body: str(payload.toolName, 'A tool') + ' requires your confirmation',
57
+ }),
58
+
59
+ 'activity.complete': (payload) => ({
60
+ title: 'Activity Complete',
61
+ body: str(payload.summary, 'An activity has completed'),
62
+ }),
63
+
64
+ 'quick_chat.response_ready': (payload) => ({
65
+ title: 'Response Ready',
66
+ body: str(payload.preview, 'Your quick chat response is ready'),
67
+ }),
68
+
69
+ 'voice.response_ready': (payload) => ({
70
+ title: 'Voice Response',
71
+ body: str(payload.preview, 'A voice response is ready'),
72
+ }),
73
+
74
+ 'ride_shotgun.invitation': (payload) => ({
75
+ title: 'Ride Shotgun',
76
+ body: str(payload.message, 'You have been invited to ride shotgun'),
77
+ }),
78
+ };
79
+
80
+ /**
81
+ * Compose fallback notification copy for a signal when the decision
82
+ * engine's LLM path is unavailable.
83
+ *
84
+ * Returns a map of channel -> RenderedChannelCopy for the requested channels.
85
+ * All channels currently receive the same template output; per-channel
86
+ * customisation can be layered on later.
87
+ */
88
+ export function composeFallbackCopy(
89
+ signal: NotificationSignal,
90
+ channels: NotificationChannel[],
91
+ ): Partial<Record<NotificationChannel, RenderedChannelCopy>> {
92
+ const template = TEMPLATES[signal.sourceEventName];
93
+
94
+ const baseCopy: RenderedChannelCopy = template
95
+ ? template(signal.contextPayload)
96
+ : buildGenericCopy(signal);
97
+
98
+ const result: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
99
+ for (const ch of channels) {
100
+ result[ch] = { ...baseCopy };
101
+ }
102
+ return result;
103
+ }
104
+
105
+ /**
106
+ * Build generic copy when no template matches. Uses the signal's
107
+ * sourceEventName and attention hints to produce something reasonable.
108
+ */
109
+ function buildGenericCopy(signal: NotificationSignal): RenderedChannelCopy {
110
+ const humanName = signal.sourceEventName.replace(/[._]/g, ' ');
111
+ const urgencyPrefix = signal.attentionHints.urgency === 'high' ? 'Urgent: ' : '';
112
+ const actionSuffix = signal.attentionHints.requiresAction ? ' — action required' : '';
113
+
114
+ return {
115
+ title: 'Notification',
116
+ body: `${urgencyPrefix}${humanName}${actionSuffix}`,
117
+ };
118
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Notification decision engine.
3
+ *
4
+ * Evaluates a NotificationSignal against available channels and user
5
+ * preferences, producing a NotificationDecision that tells the broadcaster
6
+ * whether and how to notify the user. Uses the provider abstraction to
7
+ * call the LLM with forced tool_choice output, falling back to a
8
+ * deterministic heuristic when the model is unavailable or returns
9
+ * invalid output.
10
+ */
11
+
12
+ import { v4 as uuid } from 'uuid';
13
+ import { getConfig } from '../config/loader.js';
14
+ import { getLogger } from '../util/logger.js';
15
+ import { getConfiguredProvider, createTimeout, extractToolUse, userMessage } from '../providers/provider-send-message.js';
16
+ import { createDecision } from './decisions-store.js';
17
+ import { getPreferenceSummary } from './preference-summary.js';
18
+ import type { NotificationSignal } from './signal.js';
19
+ import type { NotificationChannel, NotificationDecision, RenderedChannelCopy } from './types.js';
20
+
21
+ const log = getLogger('notification-decision-engine');
22
+
23
+ const DECISION_TIMEOUT_MS = 15_000;
24
+ const PROMPT_VERSION = 'v1';
25
+
26
+ // ── System prompt ──────────────────────────────────────────────────────
27
+
28
+ function buildSystemPrompt(
29
+ availableChannels: NotificationChannel[],
30
+ preferenceContext?: string,
31
+ ): string {
32
+ const sections: string[] = [
33
+ `You are a notification routing engine. Given a signal describing an event, decide whether the user should be notified, on which channel(s), and compose the notification copy.`,
34
+ ``,
35
+ `Available notification channels: ${availableChannels.join(', ')}`,
36
+ ];
37
+
38
+ if (preferenceContext) {
39
+ sections.push(
40
+ ``,
41
+ `<user-preferences>`,
42
+ preferenceContext,
43
+ `</user-preferences>`,
44
+ );
45
+ }
46
+
47
+ sections.push(
48
+ ``,
49
+ `Guidelines:`,
50
+ `- Only notify when the signal genuinely warrants user attention.`,
51
+ `- Prefer fewer channels unless the signal is urgent.`,
52
+ `- For high-urgency signals that require action, notify on all available channels.`,
53
+ `- For low-urgency background events, suppress unless they match user preferences.`,
54
+ `- Keep notification copy concise and actionable.`,
55
+ `- Generate a stable dedupeKey derived from the signal context so duplicate signals can be suppressed.`,
56
+ ``,
57
+ `You MUST respond using the \`record_notification_decision\` tool. Do not respond with text.`,
58
+ );
59
+
60
+ return sections.join('\n');
61
+ }
62
+
63
+ // ── User prompt ────────────────────────────────────────────────────────
64
+
65
+ function buildUserPrompt(signal: NotificationSignal): string {
66
+ const parts: string[] = [
67
+ `Signal ID: ${signal.signalId}`,
68
+ `Source event: ${signal.sourceEventName}`,
69
+ `Source channel: ${signal.sourceChannel}`,
70
+ `Urgency: ${signal.attentionHints.urgency}`,
71
+ `Requires action: ${signal.attentionHints.requiresAction}`,
72
+ `Is async background: ${signal.attentionHints.isAsyncBackground}`,
73
+ `User is viewing source now: ${signal.attentionHints.visibleInSourceNow}`,
74
+ ];
75
+
76
+ if (signal.attentionHints.deadlineAt) {
77
+ parts.push(`Deadline: ${new Date(signal.attentionHints.deadlineAt).toISOString()}`);
78
+ }
79
+
80
+ const payloadStr = JSON.stringify(signal.contextPayload);
81
+ if (payloadStr.length > 2) {
82
+ parts.push(``, `Context payload:`, payloadStr);
83
+ }
84
+
85
+ return `Evaluate this notification signal:\n\n${parts.join('\n')}`;
86
+ }
87
+
88
+ // ── Tool definition ────────────────────────────────────────────────────
89
+
90
+ function buildDecisionTool(availableChannels: NotificationChannel[]) {
91
+ return {
92
+ name: 'record_notification_decision',
93
+ description: 'Record the notification routing decision for this signal',
94
+ input_schema: {
95
+ type: 'object' as const,
96
+ properties: {
97
+ shouldNotify: {
98
+ type: 'boolean',
99
+ description: 'Whether the user should be notified about this signal',
100
+ },
101
+ selectedChannels: {
102
+ type: 'array',
103
+ items: {
104
+ type: 'string',
105
+ enum: availableChannels,
106
+ },
107
+ description: 'Which channels to deliver the notification on',
108
+ },
109
+ reasoningSummary: {
110
+ type: 'string',
111
+ description: 'Brief explanation of why this routing decision was made',
112
+ },
113
+ renderedCopy: {
114
+ type: 'object',
115
+ description: 'Notification copy keyed by channel name',
116
+ properties: Object.fromEntries(
117
+ availableChannels.map((ch) => [
118
+ ch,
119
+ {
120
+ type: 'object',
121
+ properties: {
122
+ title: { type: 'string', description: 'Short notification title' },
123
+ body: { type: 'string', description: 'Notification body text' },
124
+ threadTitle: { type: 'string', description: 'Optional thread title for grouped notifications' },
125
+ threadSeedMessage: { type: 'string', description: 'Optional seed message for a new thread' },
126
+ },
127
+ required: ['title', 'body'],
128
+ },
129
+ ]),
130
+ ),
131
+ },
132
+ deepLinkTarget: {
133
+ type: 'object',
134
+ description: 'Optional deep link metadata for navigating to the source context',
135
+ },
136
+ dedupeKey: {
137
+ type: 'string',
138
+ description: 'A stable key derived from the signal to deduplicate repeated notifications for the same event',
139
+ },
140
+ confidence: {
141
+ type: 'number',
142
+ description: 'Confidence in the decision (0.0-1.0)',
143
+ },
144
+ },
145
+ required: ['shouldNotify', 'selectedChannels', 'reasoningSummary', 'renderedCopy', 'dedupeKey', 'confidence'],
146
+ },
147
+ };
148
+ }
149
+
150
+ // ── Deterministic fallback ─────────────────────────────────────────────
151
+
152
+ function buildFallbackDecision(
153
+ signal: NotificationSignal,
154
+ availableChannels: NotificationChannel[],
155
+ ): NotificationDecision {
156
+ const isHighUrgencyAction =
157
+ signal.attentionHints.urgency === 'high' && signal.attentionHints.requiresAction;
158
+
159
+ if (isHighUrgencyAction) {
160
+ const copy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
161
+ for (const ch of availableChannels) {
162
+ copy[ch] = {
163
+ title: signal.sourceEventName,
164
+ body: `Action required: ${signal.sourceEventName}`,
165
+ };
166
+ }
167
+
168
+ return {
169
+ shouldNotify: true,
170
+ selectedChannels: [...availableChannels],
171
+ reasoningSummary: 'Fallback: high urgency + requires action',
172
+ renderedCopy: copy,
173
+ dedupeKey: `fallback:${signal.sourceEventName}:${signal.sourceSessionId}:${signal.createdAt}`,
174
+ confidence: 0.3,
175
+ fallbackUsed: true,
176
+ };
177
+ }
178
+
179
+ return {
180
+ shouldNotify: false,
181
+ selectedChannels: [],
182
+ reasoningSummary: 'Fallback: suppressed (not high urgency + requires action)',
183
+ renderedCopy: {},
184
+ dedupeKey: `fallback:${signal.sourceEventName}:${signal.sourceSessionId}:${signal.createdAt}`,
185
+ confidence: 0.3,
186
+ fallbackUsed: true,
187
+ };
188
+ }
189
+
190
+ // ── Validation ─────────────────────────────────────────────────────────
191
+
192
+ const VALID_CHANNELS = new Set<string>(['macos', 'telegram']);
193
+
194
+ function validateDecisionOutput(
195
+ input: Record<string, unknown>,
196
+ availableChannels: NotificationChannel[],
197
+ ): NotificationDecision | null {
198
+ if (typeof input.shouldNotify !== 'boolean') return null;
199
+ if (typeof input.reasoningSummary !== 'string') return null;
200
+ if (typeof input.dedupeKey !== 'string') return null;
201
+
202
+ if (!Array.isArray(input.selectedChannels)) return null;
203
+ const validatedChannels = (input.selectedChannels as unknown[]).filter(
204
+ (ch): ch is NotificationChannel =>
205
+ typeof ch === 'string' && VALID_CHANNELS.has(ch) && availableChannels.includes(ch as NotificationChannel),
206
+ );
207
+ const validChannels = [...new Set(validatedChannels)];
208
+
209
+ const confidence = typeof input.confidence === 'number'
210
+ ? Math.max(0, Math.min(1, input.confidence))
211
+ : 0.5;
212
+
213
+ // Validate renderedCopy
214
+ const renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
215
+ if (input.renderedCopy && typeof input.renderedCopy === 'object') {
216
+ const copyObj = input.renderedCopy as Record<string, unknown>;
217
+ for (const ch of validChannels) {
218
+ const chCopy = copyObj[ch];
219
+ if (chCopy && typeof chCopy === 'object') {
220
+ const c = chCopy as Record<string, unknown>;
221
+ if (typeof c.title === 'string' && typeof c.body === 'string') {
222
+ renderedCopy[ch] = {
223
+ title: c.title,
224
+ body: c.body,
225
+ threadTitle: typeof c.threadTitle === 'string' ? c.threadTitle : undefined,
226
+ threadSeedMessage: typeof c.threadSeedMessage === 'string' ? c.threadSeedMessage : undefined,
227
+ };
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ const deepLinkTarget = input.deepLinkTarget && typeof input.deepLinkTarget === 'object'
234
+ ? input.deepLinkTarget as Record<string, unknown>
235
+ : undefined;
236
+
237
+ return {
238
+ shouldNotify: input.shouldNotify,
239
+ selectedChannels: validChannels,
240
+ reasoningSummary: input.reasoningSummary,
241
+ renderedCopy,
242
+ deepLinkTarget,
243
+ dedupeKey: input.dedupeKey,
244
+ confidence,
245
+ fallbackUsed: false,
246
+ };
247
+ }
248
+
249
+ // ── Core evaluation function ───────────────────────────────────────────
250
+
251
+ export interface EvaluateSignalOptions {
252
+ shadowMode?: boolean;
253
+ }
254
+
255
+ export async function evaluateSignal(
256
+ signal: NotificationSignal,
257
+ availableChannels: NotificationChannel[],
258
+ preferenceContext?: string,
259
+ options?: EvaluateSignalOptions,
260
+ ): Promise<NotificationDecision> {
261
+ const config = getConfig();
262
+ const decisionModel = config.notifications.decisionModel;
263
+
264
+ // When no explicit preference context is provided, load the user's
265
+ // stored notification preferences from the memory-backed store.
266
+ // Wrapped in try/catch so a DB failure doesn't break the decision path.
267
+ let resolvedPreferenceContext = preferenceContext;
268
+ if (resolvedPreferenceContext === undefined) {
269
+ try {
270
+ resolvedPreferenceContext = getPreferenceSummary(signal.assistantId) ?? undefined;
271
+ } catch (err) {
272
+ const errMsg = err instanceof Error ? err.message : String(err);
273
+ log.warn({ err: errMsg, assistantId: signal.assistantId }, 'Failed to load preference summary, proceeding without preferences');
274
+ resolvedPreferenceContext = undefined;
275
+ }
276
+ }
277
+
278
+ const provider = getConfiguredProvider();
279
+ if (!provider) {
280
+ log.warn('Configured provider unavailable for notification decision, using fallback');
281
+ const decision = buildFallbackDecision(signal, availableChannels);
282
+ decision.persistedDecisionId = persistDecision(signal, decision);
283
+ return decision;
284
+ }
285
+
286
+ let decision: NotificationDecision;
287
+ try {
288
+ decision = await classifyWithLLM(signal, availableChannels, resolvedPreferenceContext, decisionModel);
289
+ } catch (err) {
290
+ const errMsg = err instanceof Error ? err.message : String(err);
291
+ log.warn({ err: errMsg }, 'Notification decision LLM call failed, using fallback');
292
+ decision = buildFallbackDecision(signal, availableChannels);
293
+ }
294
+
295
+ decision.persistedDecisionId = persistDecision(signal, decision);
296
+
297
+ if (options?.shadowMode ?? config.notifications.shadowMode) {
298
+ log.info(
299
+ {
300
+ signalId: signal.signalId,
301
+ shouldNotify: decision.shouldNotify,
302
+ channels: decision.selectedChannels,
303
+ fallbackUsed: decision.fallbackUsed,
304
+ confidence: decision.confidence,
305
+ },
306
+ 'Shadow mode: decision logged but not dispatched',
307
+ );
308
+ }
309
+
310
+ return decision;
311
+ }
312
+
313
+ // ── LLM classification ────────────────────────────────────────────────
314
+
315
+ async function classifyWithLLM(
316
+ signal: NotificationSignal,
317
+ availableChannels: NotificationChannel[],
318
+ preferenceContext: string | undefined,
319
+ model: string,
320
+ ): Promise<NotificationDecision> {
321
+ const provider = getConfiguredProvider()!;
322
+ const { signal: abortSignal, cleanup } = createTimeout(DECISION_TIMEOUT_MS);
323
+
324
+ const systemPrompt = buildSystemPrompt(availableChannels, preferenceContext);
325
+ const prompt = buildUserPrompt(signal);
326
+ const tool = buildDecisionTool(availableChannels);
327
+
328
+ try {
329
+ const response = await provider.sendMessage(
330
+ [userMessage(prompt)],
331
+ [tool],
332
+ systemPrompt,
333
+ {
334
+ config: {
335
+ model,
336
+ max_tokens: 2048,
337
+ tool_choice: { type: 'tool' as const, name: 'record_notification_decision' },
338
+ },
339
+ signal: abortSignal,
340
+ },
341
+ );
342
+ cleanup();
343
+
344
+ const toolBlock = extractToolUse(response);
345
+ if (!toolBlock) {
346
+ log.warn('No tool_use block in notification decision response, using fallback');
347
+ return buildFallbackDecision(signal, availableChannels);
348
+ }
349
+
350
+ const validated = validateDecisionOutput(
351
+ toolBlock.input as Record<string, unknown>,
352
+ availableChannels,
353
+ );
354
+ if (!validated) {
355
+ log.warn('Invalid notification decision output from LLM, using fallback');
356
+ return buildFallbackDecision(signal, availableChannels);
357
+ }
358
+
359
+ return validated;
360
+ } finally {
361
+ cleanup();
362
+ }
363
+ }
364
+
365
+ // ── Persistence ────────────────────────────────────────────────────────
366
+
367
+ function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
368
+ try {
369
+ const decisionId = uuid();
370
+ createDecision({
371
+ id: decisionId,
372
+ notificationEventId: signal.signalId,
373
+ shouldNotify: decision.shouldNotify,
374
+ selectedChannels: decision.selectedChannels,
375
+ reasoningSummary: decision.reasoningSummary,
376
+ confidence: decision.confidence,
377
+ fallbackUsed: decision.fallbackUsed,
378
+ promptVersion: PROMPT_VERSION,
379
+ validationResults: {
380
+ dedupeKey: decision.dedupeKey,
381
+ channelCount: decision.selectedChannels.length,
382
+ hasCopy: Object.keys(decision.renderedCopy).length > 0,
383
+ },
384
+ });
385
+ return decisionId;
386
+ } catch (err) {
387
+ const errMsg = err instanceof Error ? err.message : String(err);
388
+ log.warn({ err: errMsg }, 'Failed to persist notification decision');
389
+ return undefined;
390
+ }
391
+ }