@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.
Files changed (64) 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__/session-init.benchmark.test.ts +3 -3
  11. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  12. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  13. package/src/calls/call-domain.ts +21 -21
  14. package/src/calls/call-state.ts +12 -12
  15. package/src/calls/guardian-dispatch.ts +43 -3
  16. package/src/calls/relay-server.ts +34 -39
  17. package/src/calls/twilio-routes.ts +3 -3
  18. package/src/calls/voice-session-bridge.ts +244 -0
  19. package/src/config/defaults.ts +5 -0
  20. package/src/config/notifications-schema.ts +15 -0
  21. package/src/config/schema.ts +13 -0
  22. package/src/config/types.ts +1 -0
  23. package/src/daemon/ipc-contract/notifications.ts +9 -0
  24. package/src/daemon/ipc-contract-inventory.json +2 -0
  25. package/src/daemon/ipc-contract.ts +4 -1
  26. package/src/daemon/lifecycle.ts +84 -1
  27. package/src/daemon/session-agent-loop.ts +4 -0
  28. package/src/daemon/session-process.ts +51 -0
  29. package/src/daemon/session-runtime-assembly.ts +32 -0
  30. package/src/daemon/session.ts +5 -0
  31. package/src/memory/db-init.ts +80 -0
  32. package/src/memory/guardian-action-store.ts +2 -2
  33. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  34. package/src/memory/migrations/index.ts +1 -0
  35. package/src/memory/migrations/registry.ts +5 -0
  36. package/src/memory/schema-migration.ts +1 -0
  37. package/src/memory/schema.ts +59 -0
  38. package/src/notifications/README.md +134 -0
  39. package/src/notifications/adapters/macos.ts +55 -0
  40. package/src/notifications/adapters/telegram.ts +65 -0
  41. package/src/notifications/broadcaster.ts +175 -0
  42. package/src/notifications/copy-composer.ts +118 -0
  43. package/src/notifications/decision-engine.ts +391 -0
  44. package/src/notifications/decisions-store.ts +158 -0
  45. package/src/notifications/deliveries-store.ts +130 -0
  46. package/src/notifications/destination-resolver.ts +54 -0
  47. package/src/notifications/deterministic-checks.ts +187 -0
  48. package/src/notifications/emit-signal.ts +191 -0
  49. package/src/notifications/events-store.ts +145 -0
  50. package/src/notifications/preference-extractor.ts +223 -0
  51. package/src/notifications/preference-summary.ts +110 -0
  52. package/src/notifications/preferences-store.ts +142 -0
  53. package/src/notifications/runtime-dispatch.ts +100 -0
  54. package/src/notifications/signal.ts +24 -0
  55. package/src/notifications/types.ts +75 -0
  56. package/src/runtime/http-server.ts +10 -0
  57. package/src/runtime/pending-interactions.ts +73 -0
  58. package/src/runtime/routes/approval-routes.ts +179 -0
  59. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  60. package/src/runtime/routes/conversation-routes.ts +31 -1
  61. package/src/runtime/routes/run-routes.ts +1 -1
  62. package/src/runtime/run-orchestrator.ts +157 -2
  63. package/src/tools/browser/browser-manager.ts +1 -1
  64. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -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
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * CRUD operations for notification decisions.
3
+ *
4
+ * Each row records the routing decision made by the decision engine for
5
+ * a given notification event: whether to notify, which channels, and the
6
+ * reasoning behind it. This provides a full audit trail of how signals
7
+ * were routed.
8
+ */
9
+
10
+ import { and, desc, eq } from 'drizzle-orm';
11
+ import { getDb } from '../memory/db.js';
12
+ import { notificationDecisions, notificationEvents } from '../memory/schema.js';
13
+
14
+ export interface NotificationDecisionRow {
15
+ id: string;
16
+ notificationEventId: string;
17
+ shouldNotify: boolean;
18
+ selectedChannels: string; // JSON array
19
+ reasoningSummary: string;
20
+ confidence: number;
21
+ fallbackUsed: boolean;
22
+ promptVersion: string | null;
23
+ validationResults: string | null; // JSON
24
+ createdAt: number;
25
+ }
26
+
27
+ function rowToDecision(row: typeof notificationDecisions.$inferSelect): NotificationDecisionRow {
28
+ return {
29
+ id: row.id,
30
+ notificationEventId: row.notificationEventId,
31
+ shouldNotify: row.shouldNotify === 1,
32
+ selectedChannels: row.selectedChannels,
33
+ reasoningSummary: row.reasoningSummary,
34
+ confidence: row.confidence,
35
+ fallbackUsed: row.fallbackUsed === 1,
36
+ promptVersion: row.promptVersion,
37
+ validationResults: row.validationResults,
38
+ createdAt: row.createdAt,
39
+ };
40
+ }
41
+
42
+ export interface CreateDecisionParams {
43
+ id: string;
44
+ notificationEventId: string;
45
+ shouldNotify: boolean;
46
+ selectedChannels: string[]; // will be serialised to JSON
47
+ reasoningSummary: string;
48
+ confidence: number;
49
+ fallbackUsed: boolean;
50
+ promptVersion?: string;
51
+ validationResults?: Record<string, unknown>;
52
+ }
53
+
54
+ /** Insert a new decision record. */
55
+ export function createDecision(params: CreateDecisionParams): NotificationDecisionRow {
56
+ const db = getDb();
57
+ const now = Date.now();
58
+
59
+ const row = {
60
+ id: params.id,
61
+ notificationEventId: params.notificationEventId,
62
+ shouldNotify: params.shouldNotify ? 1 : 0,
63
+ selectedChannels: JSON.stringify(params.selectedChannels),
64
+ reasoningSummary: params.reasoningSummary,
65
+ confidence: params.confidence,
66
+ fallbackUsed: params.fallbackUsed ? 1 : 0,
67
+ promptVersion: params.promptVersion ?? null,
68
+ validationResults: params.validationResults ? JSON.stringify(params.validationResults) : null,
69
+ createdAt: now,
70
+ };
71
+
72
+ db.insert(notificationDecisions).values(row).run();
73
+
74
+ return {
75
+ ...row,
76
+ shouldNotify: params.shouldNotify,
77
+ fallbackUsed: params.fallbackUsed,
78
+ };
79
+ }
80
+
81
+ /** Fetch a single decision by ID. */
82
+ export function getDecisionById(id: string): NotificationDecisionRow | null {
83
+ const db = getDb();
84
+ const row = db
85
+ .select()
86
+ .from(notificationDecisions)
87
+ .where(eq(notificationDecisions.id, id))
88
+ .get();
89
+ if (!row) return null;
90
+ return rowToDecision(row);
91
+ }
92
+
93
+ /** Fetch a decision by its parent event ID. */
94
+ export function getDecisionByEventId(eventId: string): NotificationDecisionRow | null {
95
+ const db = getDb();
96
+ const row = db
97
+ .select()
98
+ .from(notificationDecisions)
99
+ .where(eq(notificationDecisions.notificationEventId, eventId))
100
+ .get();
101
+ if (!row) return null;
102
+ return rowToDecision(row);
103
+ }
104
+
105
+ export interface ListDecisionsFilters {
106
+ shouldNotify?: boolean;
107
+ limit?: number;
108
+ }
109
+
110
+ /** List decisions for an assistant with optional filters. */
111
+ export function listDecisions(
112
+ assistantId: string,
113
+ filters?: ListDecisionsFilters,
114
+ ): NotificationDecisionRow[] {
115
+ const db = getDb();
116
+
117
+ // Join through notificationEvents to filter by assistantId
118
+ const conditions = [eq(notificationEvents.assistantId, assistantId)];
119
+
120
+ if (filters?.shouldNotify !== undefined) {
121
+ conditions.push(eq(notificationDecisions.shouldNotify, filters.shouldNotify ? 1 : 0));
122
+ }
123
+
124
+ const limit = filters?.limit ?? 50;
125
+
126
+ const rows = db
127
+ .select({
128
+ id: notificationDecisions.id,
129
+ notificationEventId: notificationDecisions.notificationEventId,
130
+ shouldNotify: notificationDecisions.shouldNotify,
131
+ selectedChannels: notificationDecisions.selectedChannels,
132
+ reasoningSummary: notificationDecisions.reasoningSummary,
133
+ confidence: notificationDecisions.confidence,
134
+ fallbackUsed: notificationDecisions.fallbackUsed,
135
+ promptVersion: notificationDecisions.promptVersion,
136
+ validationResults: notificationDecisions.validationResults,
137
+ createdAt: notificationDecisions.createdAt,
138
+ })
139
+ .from(notificationDecisions)
140
+ .innerJoin(notificationEvents, eq(notificationDecisions.notificationEventId, notificationEvents.id))
141
+ .where(and(...conditions))
142
+ .orderBy(desc(notificationDecisions.createdAt))
143
+ .limit(limit)
144
+ .all();
145
+
146
+ return rows.map((row) => ({
147
+ id: row.id,
148
+ notificationEventId: row.notificationEventId,
149
+ shouldNotify: row.shouldNotify === 1,
150
+ selectedChannels: row.selectedChannels,
151
+ reasoningSummary: row.reasoningSummary,
152
+ confidence: row.confidence,
153
+ fallbackUsed: row.fallbackUsed === 1,
154
+ promptVersion: row.promptVersion,
155
+ validationResults: row.validationResults,
156
+ createdAt: row.createdAt,
157
+ }));
158
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Delivery audit records for notifications.
3
+ *
4
+ * Each row represents a single attempt to deliver a notification decision
5
+ * to a specific channel and destination. Multiple attempts for the same
6
+ * (decision, channel, destination) are tracked via the `attempt` counter.
7
+ */
8
+
9
+ import { and, eq } from 'drizzle-orm';
10
+ import { getDb } from '../memory/db.js';
11
+ import { notificationDeliveries } from '../memory/schema.js';
12
+ import type { NotificationChannel, NotificationDeliveryStatus } from './types.js';
13
+
14
+ export interface NotificationDeliveryRow {
15
+ id: string;
16
+ notificationDecisionId: string;
17
+ assistantId: string;
18
+ channel: string;
19
+ destination: string;
20
+ status: string;
21
+ attempt: number;
22
+ renderedTitle: string | null;
23
+ renderedBody: string | null;
24
+ errorCode: string | null;
25
+ errorMessage: string | null;
26
+ sentAt: number | null;
27
+ createdAt: number;
28
+ updatedAt: number;
29
+ }
30
+
31
+ function rowToDelivery(row: typeof notificationDeliveries.$inferSelect): NotificationDeliveryRow {
32
+ return {
33
+ id: row.id,
34
+ notificationDecisionId: row.notificationDecisionId,
35
+ assistantId: row.assistantId,
36
+ channel: row.channel,
37
+ destination: row.destination,
38
+ status: row.status,
39
+ attempt: row.attempt,
40
+ renderedTitle: row.renderedTitle,
41
+ renderedBody: row.renderedBody,
42
+ errorCode: row.errorCode,
43
+ errorMessage: row.errorMessage,
44
+ sentAt: row.sentAt,
45
+ createdAt: row.createdAt,
46
+ updatedAt: row.updatedAt,
47
+ };
48
+ }
49
+
50
+ export interface CreateDeliveryParams {
51
+ id: string;
52
+ notificationDecisionId: string;
53
+ assistantId: string;
54
+ channel: NotificationChannel;
55
+ destination: string;
56
+ status: NotificationDeliveryStatus;
57
+ attempt: number;
58
+ renderedTitle?: string;
59
+ renderedBody?: string;
60
+ errorCode?: string;
61
+ errorMessage?: string;
62
+ sentAt?: number;
63
+ }
64
+
65
+ /** Create a new delivery audit record. */
66
+ export function createDelivery(params: CreateDeliveryParams): NotificationDeliveryRow {
67
+ const db = getDb();
68
+ const now = Date.now();
69
+
70
+ const row = {
71
+ id: params.id,
72
+ notificationDecisionId: params.notificationDecisionId,
73
+ assistantId: params.assistantId,
74
+ channel: params.channel,
75
+ destination: params.destination,
76
+ status: params.status,
77
+ attempt: params.attempt,
78
+ renderedTitle: params.renderedTitle ?? null,
79
+ renderedBody: params.renderedBody ?? null,
80
+ errorCode: params.errorCode ?? null,
81
+ errorMessage: params.errorMessage ?? null,
82
+ sentAt: params.sentAt ?? null,
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ };
86
+
87
+ db.insert(notificationDeliveries).values(row).run();
88
+
89
+ return row;
90
+ }
91
+
92
+ /** Update the status of an existing delivery record. */
93
+ export function updateDeliveryStatus(
94
+ id: string,
95
+ status: NotificationDeliveryStatus,
96
+ error?: { code?: string; message?: string },
97
+ ): boolean {
98
+ const db = getDb();
99
+ const now = Date.now();
100
+
101
+ const updates: Record<string, unknown> = { status, updatedAt: now };
102
+ if (status === 'sent') {
103
+ updates.sentAt = now;
104
+ }
105
+ if (error?.code) {
106
+ updates.errorCode = error.code;
107
+ }
108
+ if (error?.message) {
109
+ updates.errorMessage = error.message;
110
+ }
111
+
112
+ const result = db
113
+ .update(notificationDeliveries)
114
+ .set(updates)
115
+ .where(eq(notificationDeliveries.id, id))
116
+ .run() as unknown as { changes?: number };
117
+
118
+ return (result.changes ?? 0) > 0;
119
+ }
120
+
121
+ /** List all delivery records for a given notification decision. */
122
+ export function listDeliveries(decisionId: string): NotificationDeliveryRow[] {
123
+ const db = getDb();
124
+ const rows = db
125
+ .select()
126
+ .from(notificationDeliveries)
127
+ .where(eq(notificationDeliveries.notificationDecisionId, decisionId))
128
+ .all();
129
+ return rows.map(rowToDelivery);
130
+ }