@vellumai/assistant 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +84 -1
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -0
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +10 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +31 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,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
|
+
}
|