@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,54 @@
1
+ /**
2
+ * Resolves per-channel destination endpoints for notification delivery.
3
+ *
4
+ * - macOS: no external endpoint needed — delivery goes through the IPC
5
+ * broadcast mechanism to connected desktop clients.
6
+ * - Telegram: requires a chat ID sourced from the guardian binding for the
7
+ * assistant.
8
+ */
9
+
10
+ import { getActiveBinding } from '../memory/channel-guardian-store.js';
11
+ import type { NotificationChannel, ChannelDestination } from './types.js';
12
+
13
+ /**
14
+ * Resolve destination information for each requested channel.
15
+ *
16
+ * Returns a map keyed by channel name. Channels that cannot be resolved
17
+ * (e.g. no Telegram binding configured) are omitted from the result.
18
+ */
19
+ export function resolveDestinations(
20
+ assistantId: string,
21
+ channels: NotificationChannel[],
22
+ ): Map<NotificationChannel, ChannelDestination> {
23
+ const result = new Map<NotificationChannel, ChannelDestination>();
24
+
25
+ for (const channel of channels) {
26
+ switch (channel) {
27
+ case 'macos': {
28
+ // macOS delivery is local IPC — no external endpoint required.
29
+ result.set('macos', { channel: 'macos' });
30
+ break;
31
+ }
32
+ case 'telegram': {
33
+ const binding = getActiveBinding(assistantId, 'telegram');
34
+ if (binding) {
35
+ result.set('telegram', {
36
+ channel: 'telegram',
37
+ endpoint: binding.guardianDeliveryChatId,
38
+ metadata: {
39
+ externalUserId: binding.guardianExternalUserId,
40
+ },
41
+ });
42
+ }
43
+ // If no binding exists, skip — the channel is not configured.
44
+ break;
45
+ }
46
+ default: {
47
+ // Unknown channel — skip silently.
48
+ break;
49
+ }
50
+ }
51
+ }
52
+
53
+ return result;
54
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Deterministic pre-send gate checks for notification decisions.
3
+ *
4
+ * These checks run after the decision engine produces a NotificationDecision
5
+ * and before the broadcaster dispatches. They enforce hard invariants that
6
+ * the LLM cannot override: channel availability, source-active suppression,
7
+ * deduplication, and schema validity.
8
+ */
9
+
10
+ import { and, eq } from 'drizzle-orm';
11
+ import { getDb } from '../memory/db.js';
12
+ import { notificationEvents } from '../memory/schema.js';
13
+ import { getLogger } from '../util/logger.js';
14
+ import type { NotificationSignal } from './signal.js';
15
+ import type { NotificationChannel, NotificationDecision } from './types.js';
16
+
17
+ const log = getLogger('notification-deterministic-checks');
18
+
19
+ export interface CheckResult {
20
+ passed: boolean;
21
+ reason?: string;
22
+ }
23
+
24
+ export interface DeterministicCheckContext {
25
+ /** Channels that are currently connected and available for delivery. */
26
+ connectedChannels: NotificationChannel[];
27
+ /** Dedupe window in milliseconds. Events with the same dedupeKey within this window are suppressed. */
28
+ dedupeWindowMs?: number;
29
+ }
30
+
31
+ const DEFAULT_DEDUPE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
32
+
33
+ /**
34
+ * Run all deterministic pre-send checks against a decision.
35
+ * Returns passed=false if any check fails, with a reason describing
36
+ * which check blocked the notification.
37
+ */
38
+ export async function runDeterministicChecks(
39
+ signal: NotificationSignal,
40
+ decision: NotificationDecision,
41
+ context: DeterministicCheckContext,
42
+ ): Promise<CheckResult> {
43
+ // Check 1: Decision schema validity (fail-closed)
44
+ const schemaCheck = checkDecisionSchema(decision);
45
+ if (!schemaCheck.passed) {
46
+ log.info({ signalId: signal.signalId, reason: schemaCheck.reason }, 'Deterministic check failed: schema');
47
+ return schemaCheck;
48
+ }
49
+
50
+ // Check 2: Source-active suppression
51
+ const sourceActiveCheck = checkSourceActiveSuppression(signal);
52
+ if (!sourceActiveCheck.passed) {
53
+ log.info({ signalId: signal.signalId, reason: sourceActiveCheck.reason }, 'Deterministic check failed: source active');
54
+ return sourceActiveCheck;
55
+ }
56
+
57
+ // Check 3: Channel availability
58
+ const channelCheck = checkChannelAvailability(decision, context.connectedChannels);
59
+ if (!channelCheck.passed) {
60
+ log.info({ signalId: signal.signalId, reason: channelCheck.reason }, 'Deterministic check failed: channel availability');
61
+ return channelCheck;
62
+ }
63
+
64
+ // Check 4: Dedupe
65
+ const dedupeCheck = checkDedupe(signal, decision, context.dedupeWindowMs ?? DEFAULT_DEDUPE_WINDOW_MS);
66
+ if (!dedupeCheck.passed) {
67
+ log.info({ signalId: signal.signalId, reason: dedupeCheck.reason }, 'Deterministic check failed: dedupe');
68
+ return dedupeCheck;
69
+ }
70
+
71
+ return { passed: true };
72
+ }
73
+
74
+ // ── Individual checks ──────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Fail-closed schema validation. If the decision is missing required
78
+ * fields or has invalid types, block the notification.
79
+ */
80
+ function checkDecisionSchema(decision: NotificationDecision): CheckResult {
81
+ if (typeof decision.shouldNotify !== 'boolean') {
82
+ return { passed: false, reason: 'Invalid decision: shouldNotify is not a boolean' };
83
+ }
84
+ if (!Array.isArray(decision.selectedChannels)) {
85
+ return { passed: false, reason: 'Invalid decision: selectedChannels is not an array' };
86
+ }
87
+ if (typeof decision.reasoningSummary !== 'string') {
88
+ return { passed: false, reason: 'Invalid decision: reasoningSummary is not a string' };
89
+ }
90
+ if (typeof decision.dedupeKey !== 'string' || decision.dedupeKey.length === 0) {
91
+ return { passed: false, reason: 'Invalid decision: dedupeKey is missing or empty' };
92
+ }
93
+ if (typeof decision.confidence !== 'number' || !Number.isFinite(decision.confidence)) {
94
+ return { passed: false, reason: 'Invalid decision: confidence is not a finite number' };
95
+ }
96
+ return { passed: true };
97
+ }
98
+
99
+ /**
100
+ * If the user is already looking at the source context (visibleInSourceNow),
101
+ * suppress the notification to avoid redundant alerts.
102
+ */
103
+ function checkSourceActiveSuppression(signal: NotificationSignal): CheckResult {
104
+ if (signal.attentionHints.visibleInSourceNow) {
105
+ return {
106
+ passed: false,
107
+ reason: 'Source-active suppression: user is already viewing the source context',
108
+ };
109
+ }
110
+ return { passed: true };
111
+ }
112
+
113
+ /**
114
+ * Verify that at least one of the selected channels is actually
115
+ * connected and available for delivery.
116
+ */
117
+ function checkChannelAvailability(
118
+ decision: NotificationDecision,
119
+ connectedChannels: NotificationChannel[],
120
+ ): CheckResult {
121
+ if (!decision.shouldNotify) {
122
+ // Not notifying — channel availability is irrelevant
123
+ return { passed: true };
124
+ }
125
+
126
+ const connectedSet = new Set(connectedChannels);
127
+ const availableSelected = decision.selectedChannels.filter((ch) => connectedSet.has(ch));
128
+
129
+ if (availableSelected.length === 0) {
130
+ return {
131
+ passed: false,
132
+ reason: `Channel availability: none of the selected channels (${decision.selectedChannels.join(', ')}) are connected`,
133
+ };
134
+ }
135
+
136
+ return { passed: true };
137
+ }
138
+
139
+ /**
140
+ * Check if a signal with the same dedupeKey was already processed
141
+ * within the dedupe window. Uses the events-store table directly.
142
+ */
143
+ function checkDedupe(
144
+ signal: NotificationSignal,
145
+ decision: NotificationDecision,
146
+ windowMs: number,
147
+ ): CheckResult {
148
+ if (!decision.dedupeKey) {
149
+ return { passed: true };
150
+ }
151
+
152
+ try {
153
+ const db = getDb();
154
+ const cutoff = Date.now() - windowMs;
155
+
156
+ const existing = db
157
+ .select({ id: notificationEvents.id, createdAt: notificationEvents.createdAt })
158
+ .from(notificationEvents)
159
+ .where(
160
+ and(
161
+ eq(notificationEvents.assistantId, signal.assistantId),
162
+ eq(notificationEvents.dedupeKey, decision.dedupeKey),
163
+ ),
164
+ )
165
+ .all();
166
+
167
+ // Filter by created_at > cutoff (the events store already checked
168
+ // dedupe on insert, but this catches cases where the engine is
169
+ // re-evaluating a signal that was previously stored).
170
+ for (const row of existing) {
171
+ // The current signal's own event row should not count as a duplicate
172
+ if (row.id === signal.signalId) continue;
173
+ // Only consider events within the dedupe window
174
+ if (row.createdAt < cutoff) continue;
175
+ // If any other event with the same dedupeKey exists within the window, suppress
176
+ return {
177
+ passed: false,
178
+ reason: `Dedupe: signal with dedupeKey "${decision.dedupeKey}" was already processed`,
179
+ };
180
+ }
181
+ } catch (err) {
182
+ const errMsg = err instanceof Error ? err.message : String(err);
183
+ log.warn({ err: errMsg }, 'Dedupe check failed, allowing notification through');
184
+ }
185
+
186
+ return { passed: true };
187
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Single entry point for all notification producers.
3
+ *
4
+ * emitNotificationSignal() creates a NotificationSignal, persists the event,
5
+ * and runs it through the decision engine + deterministic checks + dispatch
6
+ * pipeline.
7
+ *
8
+ * Designed for fire-and-forget usage: errors are logged but never propagated
9
+ * to the caller. The returned promise resolves even on failure.
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 { getActiveBinding } from '../memory/channel-guardian-store.js';
16
+ import { createEvent, updateEventDedupeKey } from './events-store.js';
17
+ import { evaluateSignal } from './decision-engine.js';
18
+ import { runDeterministicChecks, type DeterministicCheckContext } from './deterministic-checks.js';
19
+ import { dispatchDecision } from './runtime-dispatch.js';
20
+ import { NotificationBroadcaster } from './broadcaster.js';
21
+ import { MacOSAdapter, type BroadcastFn } from './adapters/macos.js';
22
+ import { TelegramAdapter } from './adapters/telegram.js';
23
+ import type { NotificationSignal, AttentionHints } from './signal.js';
24
+ import type { NotificationChannel } from './types.js';
25
+
26
+ const log = getLogger('emit-signal');
27
+
28
+ // ── Broadcaster singleton ──────────────────────────────────────────────
29
+
30
+ let broadcasterInstance: NotificationBroadcaster | null = null;
31
+ let registeredBroadcastFn: BroadcastFn | null = null;
32
+
33
+ /**
34
+ * Register the IPC broadcast function so the macOS adapter can deliver
35
+ * notifications through the daemon's IPC socket. Must be called once
36
+ * during daemon startup (before any signals are emitted).
37
+ */
38
+ export function registerBroadcastFn(fn: BroadcastFn): void {
39
+ registeredBroadcastFn = fn;
40
+ // Reset the broadcaster so it picks up the new broadcast function
41
+ broadcasterInstance = null;
42
+ }
43
+
44
+ function getBroadcaster(): NotificationBroadcaster {
45
+ if (!broadcasterInstance) {
46
+ const adapters = [
47
+ new TelegramAdapter(),
48
+ ];
49
+ if (registeredBroadcastFn) {
50
+ adapters.unshift(new MacOSAdapter(registeredBroadcastFn));
51
+ }
52
+ broadcasterInstance = new NotificationBroadcaster(adapters);
53
+ }
54
+ return broadcasterInstance;
55
+ }
56
+
57
+ // ── Connected channels resolution ──────────────────────────────────────
58
+
59
+ function getConnectedChannels(assistantId: string): NotificationChannel[] {
60
+ // macOS is always considered connected (IPC socket is always available
61
+ // when the daemon is running).
62
+ const channels: NotificationChannel[] = ['macos'];
63
+ // Only report Telegram as connected when there is an active guardian
64
+ // binding for this assistant. Without a binding, the destination
65
+ // resolver will fail to resolve a chat ID and dispatch will silently
66
+ // drop the message — which is worse than the decision engine knowing
67
+ // up front that the channel is unavailable.
68
+ const telegramBinding = getActiveBinding(assistantId, 'telegram');
69
+ if (telegramBinding) {
70
+ channels.push('telegram');
71
+ }
72
+ return channels;
73
+ }
74
+
75
+ // ── Public API ─────────────────────────────────────────────────────────
76
+
77
+ export interface EmitSignalParams {
78
+ /** Free-form event name, e.g. 'reminder.fired', 'schedule.complete'. */
79
+ sourceEventName: string;
80
+ /** Source channel that produced the event. */
81
+ sourceChannel: string;
82
+ /** Session or conversation ID from the source context. */
83
+ sourceSessionId: string;
84
+ /** Logical assistant ID (defaults to 'self'). */
85
+ assistantId?: string;
86
+ /** Attention hints for the decision engine. */
87
+ attentionHints: AttentionHints;
88
+ /** Arbitrary context payload passed to the decision engine. */
89
+ contextPayload?: Record<string, unknown>;
90
+ /** Optional deduplication key. */
91
+ dedupeKey?: string;
92
+ }
93
+
94
+ /**
95
+ * Emit a notification signal through the full pipeline:
96
+ * createEvent -> evaluateSignal -> runDeterministicChecks -> dispatchDecision.
97
+ *
98
+ * Fire-and-forget safe: all errors are caught and logged. The caller
99
+ * should not await this in critical paths unless it needs the result.
100
+ */
101
+ export async function emitNotificationSignal(params: EmitSignalParams): Promise<void> {
102
+ const config = getConfig();
103
+ if (!config.notifications?.enabled) {
104
+ log.debug({ sourceEventName: params.sourceEventName }, 'Notification system disabled, skipping signal');
105
+ return;
106
+ }
107
+
108
+ const signalId = uuid();
109
+ const assistantId = params.assistantId ?? 'self';
110
+
111
+ const signal: NotificationSignal = {
112
+ signalId,
113
+ assistantId,
114
+ createdAt: Date.now(),
115
+ sourceChannel: params.sourceChannel,
116
+ sourceSessionId: params.sourceSessionId,
117
+ sourceEventName: params.sourceEventName,
118
+ contextPayload: params.contextPayload ?? {},
119
+ attentionHints: params.attentionHints,
120
+ };
121
+
122
+ try {
123
+ // Step 1: Persist the event
124
+ const eventRow = createEvent({
125
+ id: signalId,
126
+ assistantId,
127
+ sourceEventName: params.sourceEventName,
128
+ sourceChannel: params.sourceChannel,
129
+ sourceSessionId: params.sourceSessionId,
130
+ attentionHints: params.attentionHints,
131
+ payload: params.contextPayload ?? {},
132
+ dedupeKey: params.dedupeKey,
133
+ });
134
+
135
+ if (!eventRow) {
136
+ log.info({ signalId, dedupeKey: params.dedupeKey }, 'Signal deduplicated at event store level');
137
+ return;
138
+ }
139
+
140
+ // Step 2: Evaluate the signal through the decision engine
141
+ const connectedChannels = getConnectedChannels(assistantId);
142
+ const decision = await evaluateSignal(signal, connectedChannels);
143
+
144
+ // Persist model-generated dedupeKey back to the event row so future
145
+ // signals can deduplicate against it (the event was created with
146
+ // only the producer's dedupeKey, which may be null).
147
+ if (decision.dedupeKey && !params.dedupeKey) {
148
+ try {
149
+ updateEventDedupeKey(signalId, decision.dedupeKey);
150
+ } catch (err) {
151
+ log.warn({ err, signalId }, 'Failed to persist decision dedupeKey to event row');
152
+ }
153
+ }
154
+
155
+ // Step 3: Run deterministic pre-send checks
156
+ if (decision.shouldNotify) {
157
+ const checkContext: DeterministicCheckContext = {
158
+ connectedChannels,
159
+ };
160
+ const checkResult = await runDeterministicChecks(signal, decision, checkContext);
161
+
162
+ if (!checkResult.passed) {
163
+ log.info(
164
+ { signalId, reason: checkResult.reason },
165
+ 'Signal blocked by deterministic checks',
166
+ );
167
+ return;
168
+ }
169
+ }
170
+
171
+ // Step 4: Dispatch through the broadcaster
172
+ const broadcaster = getBroadcaster();
173
+ const dispatchResult = await dispatchDecision(signal, decision, broadcaster);
174
+
175
+ log.info(
176
+ {
177
+ signalId,
178
+ sourceEventName: params.sourceEventName,
179
+ dispatched: dispatchResult.dispatched,
180
+ reason: dispatchResult.reason,
181
+ },
182
+ 'Signal pipeline complete',
183
+ );
184
+ } catch (err) {
185
+ const errMsg = err instanceof Error ? err.message : String(err);
186
+ log.error(
187
+ { err: errMsg, signalId, sourceEventName: params.sourceEventName },
188
+ 'Signal pipeline failed',
189
+ );
190
+ }
191
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Notification event persistence.
3
+ *
4
+ * Each row represents a single notification signal that was emitted by
5
+ * the system. The event captures the source event name, attention hints,
6
+ * and context payload. Decision/delivery records are tracked separately.
7
+ */
8
+
9
+ import { and, desc, eq } from 'drizzle-orm';
10
+ import { getDb } from '../memory/db.js';
11
+ import { notificationEvents } from '../memory/schema.js';
12
+ import type { AttentionHints } from './signal.js';
13
+
14
+ export interface NotificationEventRow {
15
+ id: string;
16
+ assistantId: string;
17
+ sourceEventName: string;
18
+ sourceChannel: string;
19
+ sourceSessionId: string;
20
+ attentionHintsJson: string;
21
+ payloadJson: string;
22
+ dedupeKey: string | null;
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ }
26
+
27
+ function rowToEvent(row: typeof notificationEvents.$inferSelect): NotificationEventRow {
28
+ return {
29
+ id: row.id,
30
+ assistantId: row.assistantId,
31
+ sourceEventName: row.sourceEventName,
32
+ sourceChannel: row.sourceChannel,
33
+ sourceSessionId: row.sourceSessionId,
34
+ attentionHintsJson: row.attentionHintsJson,
35
+ payloadJson: row.payloadJson,
36
+ dedupeKey: row.dedupeKey,
37
+ createdAt: row.createdAt,
38
+ updatedAt: row.updatedAt,
39
+ };
40
+ }
41
+
42
+ export interface CreateEventParams {
43
+ id: string;
44
+ assistantId: string;
45
+ sourceEventName: string;
46
+ sourceChannel: string;
47
+ sourceSessionId: string;
48
+ attentionHints: AttentionHints;
49
+ payload: Record<string, unknown>;
50
+ dedupeKey?: string;
51
+ }
52
+
53
+ /** Create a new notification event. Returns null if a duplicate dedupe_key exists. */
54
+ export function createEvent(params: CreateEventParams): NotificationEventRow | null {
55
+ const db = getDb();
56
+ const now = Date.now();
57
+
58
+ // Normalize empty strings to null so the falsy check below and the DB
59
+ // unique index stay in agreement (empty string is falsy in JS but would
60
+ // be stored as a non-null value in SQLite).
61
+ const normalizedDedupeKey = params.dedupeKey || null;
62
+
63
+ // If there's a dedupe key, check for duplicates first
64
+ if (normalizedDedupeKey) {
65
+ const existing = db
66
+ .select()
67
+ .from(notificationEvents)
68
+ .where(
69
+ and(
70
+ eq(notificationEvents.assistantId, params.assistantId),
71
+ eq(notificationEvents.dedupeKey, normalizedDedupeKey),
72
+ ),
73
+ )
74
+ .get();
75
+ if (existing) return null;
76
+ }
77
+
78
+ const row = {
79
+ id: params.id,
80
+ assistantId: params.assistantId,
81
+ sourceEventName: params.sourceEventName,
82
+ sourceChannel: params.sourceChannel,
83
+ sourceSessionId: params.sourceSessionId,
84
+ attentionHintsJson: JSON.stringify(params.attentionHints),
85
+ payloadJson: JSON.stringify(params.payload),
86
+ dedupeKey: normalizedDedupeKey,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ };
90
+
91
+ db.insert(notificationEvents).values(row).run();
92
+
93
+ return row;
94
+ }
95
+
96
+ /** Update the dedupeKey on an existing event (e.g. when the decision engine generates one). */
97
+ export function updateEventDedupeKey(eventId: string, dedupeKey: string): void {
98
+ const db = getDb();
99
+ db.update(notificationEvents)
100
+ .set({ dedupeKey, updatedAt: Date.now() })
101
+ .where(eq(notificationEvents.id, eventId))
102
+ .run();
103
+ }
104
+
105
+ /** Get a single notification event by ID. */
106
+ export function getEventById(id: string): NotificationEventRow | null {
107
+ const db = getDb();
108
+ const row = db
109
+ .select()
110
+ .from(notificationEvents)
111
+ .where(eq(notificationEvents.id, id))
112
+ .get();
113
+ if (!row) return null;
114
+ return rowToEvent(row);
115
+ }
116
+
117
+ export interface ListEventsFilters {
118
+ sourceEventName?: string;
119
+ limit?: number;
120
+ }
121
+
122
+ /** List notification events for an assistant with optional filters. */
123
+ export function listEvents(
124
+ assistantId: string,
125
+ filters?: ListEventsFilters,
126
+ ): NotificationEventRow[] {
127
+ const db = getDb();
128
+ const conditions = [eq(notificationEvents.assistantId, assistantId)];
129
+
130
+ if (filters?.sourceEventName) {
131
+ conditions.push(eq(notificationEvents.sourceEventName, filters.sourceEventName));
132
+ }
133
+
134
+ const limit = filters?.limit ?? 50;
135
+
136
+ const rows = db
137
+ .select()
138
+ .from(notificationEvents)
139
+ .where(and(...conditions))
140
+ .orderBy(desc(notificationEvents.createdAt))
141
+ .limit(limit)
142
+ .all();
143
+
144
+ return rows.map(rowToEvent);
145
+ }