@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,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
+ }
@@ -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
+ }