@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
package/src/memory/schema.ts
CHANGED
|
@@ -900,3 +900,62 @@ export const assistantInboxThreadState = sqliteTable('assistant_inbox_thread_sta
|
|
|
900
900
|
createdAt: integer('created_at').notNull(),
|
|
901
901
|
updatedAt: integer('updated_at').notNull(),
|
|
902
902
|
});
|
|
903
|
+
|
|
904
|
+
// ── Notification System ──────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
export const notificationEvents = sqliteTable('notification_events', {
|
|
907
|
+
id: text('id').primaryKey(),
|
|
908
|
+
assistantId: text('assistant_id').notNull(),
|
|
909
|
+
sourceEventName: text('source_event_name').notNull(),
|
|
910
|
+
sourceChannel: text('source_channel').notNull(),
|
|
911
|
+
sourceSessionId: text('source_session_id').notNull(),
|
|
912
|
+
attentionHintsJson: text('attention_hints_json').notNull().default('{}'),
|
|
913
|
+
payloadJson: text('payload_json').notNull().default('{}'),
|
|
914
|
+
dedupeKey: text('dedupe_key'),
|
|
915
|
+
createdAt: integer('created_at').notNull(),
|
|
916
|
+
updatedAt: integer('updated_at').notNull(),
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
export const notificationDecisions = sqliteTable('notification_decisions', {
|
|
920
|
+
id: text('id').primaryKey(),
|
|
921
|
+
notificationEventId: text('notification_event_id')
|
|
922
|
+
.notNull()
|
|
923
|
+
.references(() => notificationEvents.id, { onDelete: 'cascade' }),
|
|
924
|
+
shouldNotify: integer('should_notify').notNull(),
|
|
925
|
+
selectedChannels: text('selected_channels').notNull().default('[]'),
|
|
926
|
+
reasoningSummary: text('reasoning_summary').notNull(),
|
|
927
|
+
confidence: real('confidence').notNull(),
|
|
928
|
+
fallbackUsed: integer('fallback_used').notNull().default(0),
|
|
929
|
+
promptVersion: text('prompt_version'),
|
|
930
|
+
validationResults: text('validation_results'),
|
|
931
|
+
createdAt: integer('created_at').notNull(),
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
export const notificationPreferences = sqliteTable('notification_preferences', {
|
|
935
|
+
id: text('id').primaryKey(),
|
|
936
|
+
assistantId: text('assistant_id').notNull(),
|
|
937
|
+
preferenceText: text('preference_text').notNull(),
|
|
938
|
+
appliesWhenJson: text('applies_when_json').notNull().default('{}'),
|
|
939
|
+
priority: integer('priority').notNull().default(0),
|
|
940
|
+
createdAt: integer('created_at').notNull(),
|
|
941
|
+
updatedAt: integer('updated_at').notNull(),
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
export const notificationDeliveries = sqliteTable('notification_deliveries', {
|
|
945
|
+
id: text('id').primaryKey(),
|
|
946
|
+
notificationDecisionId: text('notification_decision_id')
|
|
947
|
+
.notNull()
|
|
948
|
+
.references(() => notificationDecisions.id, { onDelete: 'cascade' }),
|
|
949
|
+
assistantId: text('assistant_id').notNull(),
|
|
950
|
+
channel: text('channel').notNull(),
|
|
951
|
+
destination: text('destination').notNull(),
|
|
952
|
+
status: text('status').notNull().default('pending'),
|
|
953
|
+
attempt: integer('attempt').notNull().default(1),
|
|
954
|
+
renderedTitle: text('rendered_title'),
|
|
955
|
+
renderedBody: text('rendered_body'),
|
|
956
|
+
errorCode: text('error_code'),
|
|
957
|
+
errorMessage: text('error_message'),
|
|
958
|
+
sentAt: integer('sent_at'),
|
|
959
|
+
createdAt: integer('created_at').notNull(),
|
|
960
|
+
updatedAt: integer('updated_at').notNull(),
|
|
961
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Notification System
|
|
2
|
+
|
|
3
|
+
Signal-driven notification architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user.
|
|
4
|
+
|
|
5
|
+
## Lifecycle
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Adapters → Delivery
|
|
9
|
+
↑
|
|
10
|
+
Preference Summary
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 1. Signal
|
|
14
|
+
|
|
15
|
+
A producer calls `emitNotificationSignal()` with a free-form event name, attention hints (urgency, requiresAction, deadlineAt), and a context payload. The signal is persisted as a `notification_events` row.
|
|
16
|
+
|
|
17
|
+
### 2. Decision
|
|
18
|
+
|
|
19
|
+
The decision engine (`decision-engine.ts`) sends the signal to an LLM (configured via `notifications.decisionModel`) along with available channels and the user's preference summary. The LLM responds with a structured decision: whether to notify, which channels, rendered copy per channel, and a deduplication key.
|
|
20
|
+
|
|
21
|
+
When the LLM is unavailable or returns invalid output, a deterministic fallback fires: high-urgency + requires-action signals notify on all channels; everything else is suppressed.
|
|
22
|
+
|
|
23
|
+
### 3. Deterministic Checks
|
|
24
|
+
|
|
25
|
+
Hard invariants that the LLM cannot override (`deterministic-checks.ts`):
|
|
26
|
+
|
|
27
|
+
- **Schema validity** -- fail-closed if the decision is malformed
|
|
28
|
+
- **Source-active suppression** -- if the user is already viewing the source context, suppress
|
|
29
|
+
- **Channel availability** -- at least one selected channel must be connected
|
|
30
|
+
- **Deduplication** -- same `dedupeKey` within the dedupe window (1 hour default) is suppressed
|
|
31
|
+
|
|
32
|
+
### 4. Dispatch
|
|
33
|
+
|
|
34
|
+
`runtime-dispatch.ts` handles three early-exit cases (shouldNotify=false, shadow mode, no channels), then delegates to the broadcaster.
|
|
35
|
+
|
|
36
|
+
### 5. Broadcast and Delivery
|
|
37
|
+
|
|
38
|
+
The broadcaster (`broadcaster.ts`) iterates over selected channels, resolves destinations via `destination-resolver.ts`, pulls rendered copy from the decision (falling back to `copy-composer.ts` templates), and dispatches through channel adapters. Each delivery attempt is recorded in `notification_deliveries`.
|
|
39
|
+
|
|
40
|
+
## Key Files
|
|
41
|
+
|
|
42
|
+
| File | Purpose |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `emit-signal.ts` | Single entry point for producers; orchestrates the full pipeline |
|
|
45
|
+
| `signal.ts` | `NotificationSignal` and `AttentionHints` type definitions |
|
|
46
|
+
| `types.ts` | Channel adapter interfaces, delivery types, decision output contract |
|
|
47
|
+
| `decision-engine.ts` | LLM-based routing with forced tool_choice; deterministic fallback |
|
|
48
|
+
| `deterministic-checks.ts` | Pre-send gate checks (dedupe, source-active, channel availability) |
|
|
49
|
+
| `runtime-dispatch.ts` | Dispatch gating (shadow mode, no-op decisions) |
|
|
50
|
+
| `broadcaster.ts` | Fan-out to channel adapters with delivery audit trail |
|
|
51
|
+
| `copy-composer.ts` | Template-based fallback copy when LLM copy is unavailable |
|
|
52
|
+
| `destination-resolver.ts` | Resolves per-channel endpoints (macOS IPC, Telegram chat ID) |
|
|
53
|
+
| `adapters/macos.ts` | macOS adapter -- broadcasts `notification_intent` via IPC |
|
|
54
|
+
| `adapters/telegram.ts` | Telegram adapter -- POSTs to gateway `/deliver/telegram` |
|
|
55
|
+
| `preference-extractor.ts` | Detects notification preferences in conversation messages |
|
|
56
|
+
| `preference-summary.ts` | Builds preference context string for the decision engine prompt |
|
|
57
|
+
| `preferences-store.ts` | CRUD for `notification_preferences` table |
|
|
58
|
+
| `events-store.ts` | CRUD for `notification_events` table |
|
|
59
|
+
| `decisions-store.ts` | CRUD for `notification_decisions` table |
|
|
60
|
+
| `deliveries-store.ts` | CRUD for `notification_deliveries` table |
|
|
61
|
+
|
|
62
|
+
## How to Add a New Notification Producer
|
|
63
|
+
|
|
64
|
+
1. Import `emitNotificationSignal` from `./emit-signal.js`.
|
|
65
|
+
2. Call it with the signal parameters:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
69
|
+
|
|
70
|
+
await emitNotificationSignal({
|
|
71
|
+
sourceEventName: 'your_event_name',
|
|
72
|
+
sourceChannel: 'scheduler', // where the event originated
|
|
73
|
+
sourceSessionId: sessionId,
|
|
74
|
+
attentionHints: {
|
|
75
|
+
requiresAction: true,
|
|
76
|
+
urgency: 'high',
|
|
77
|
+
isAsyncBackground: false,
|
|
78
|
+
visibleInSourceNow: false,
|
|
79
|
+
},
|
|
80
|
+
contextPayload: { /* arbitrary data for the decision engine */ },
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
3. Optionally add a fallback copy template in `copy-composer.ts` keyed by your `sourceEventName`. Without a template, the generic fallback produces a human-readable version of the event name.
|
|
85
|
+
|
|
86
|
+
The call is fire-and-forget safe -- errors are caught and logged internally.
|
|
87
|
+
|
|
88
|
+
## Audit Trail
|
|
89
|
+
|
|
90
|
+
Three SQLite tables form the audit chain:
|
|
91
|
+
|
|
92
|
+
- **`notification_events`** -- every signal that entered the pipeline, with attention hints and context payload
|
|
93
|
+
- **`notification_decisions`** -- the routing decision for each event (shouldNotify, selectedChannels, reasoning, confidence, whether fallback was used)
|
|
94
|
+
- **`notification_deliveries`** -- per-channel delivery attempts with status (pending/sent/failed/skipped), rendered copy, and error details
|
|
95
|
+
|
|
96
|
+
Query examples:
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
-- Recent decisions that resulted in notifications
|
|
100
|
+
SELECT e.source_event_name, d.should_notify, d.selected_channels, d.reasoning_summary
|
|
101
|
+
FROM notification_decisions d
|
|
102
|
+
JOIN notification_events e ON d.notification_event_id = e.id
|
|
103
|
+
WHERE d.should_notify = 1
|
|
104
|
+
ORDER BY d.created_at DESC
|
|
105
|
+
LIMIT 20;
|
|
106
|
+
|
|
107
|
+
-- Failed deliveries
|
|
108
|
+
SELECT d.channel, d.error_message, d.rendered_title
|
|
109
|
+
FROM notification_deliveries d
|
|
110
|
+
WHERE d.status = 'failed'
|
|
111
|
+
ORDER BY d.created_at DESC;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Conversational Preferences
|
|
115
|
+
|
|
116
|
+
Users express notification preferences in natural language during conversations (e.g., "Use Telegram for urgent alerts", "Mute notifications after 10pm"). The system:
|
|
117
|
+
|
|
118
|
+
1. **Detects** preferences via `preference-extractor.ts` -- an LLM call that runs on each user message in `session-process.ts`
|
|
119
|
+
2. **Stores** them in `notification_preferences` with structured conditions (`appliesWhen`: timeRange, channels, urgencyLevels, contexts) and a priority level (0=default, 1=override, 2=critical)
|
|
120
|
+
3. **Summarizes** them at decision time via `preference-summary.ts`, which builds a compact text block injected into the decision engine's system prompt
|
|
121
|
+
|
|
122
|
+
Preferences are sanitized against prompt injection (angle brackets replaced with harmless unicode equivalents).
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
All settings live under the `notifications` key in `config.json`:
|
|
127
|
+
|
|
128
|
+
| Key | Type | Default | Description |
|
|
129
|
+
|-----|------|---------|-------------|
|
|
130
|
+
| `notifications.enabled` | boolean | `false` | Master switch for the notification pipeline |
|
|
131
|
+
| `notifications.shadowMode` | boolean | `true` | When true, decisions are logged but not dispatched |
|
|
132
|
+
| `notifications.decisionModel` | string | `"claude-haiku-4-5-20251001"` | Model used for both the decision engine and preference extraction |
|
|
133
|
+
|
|
134
|
+
Shadow mode is useful for validating decision quality before enabling live delivery. The audit trail (events + decisions) is written regardless of shadow mode.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS channel adapter — delivers notifications to connected desktop
|
|
3
|
+
* clients via the daemon's IPC broadcast mechanism.
|
|
4
|
+
*
|
|
5
|
+
* The adapter broadcasts a `notification_intent` message that the macOS
|
|
6
|
+
* client can use to display a native notification (e.g. NSUserNotification
|
|
7
|
+
* or UNUserNotificationCenter).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../../util/logger.js';
|
|
11
|
+
import type { ServerMessage } from '../../daemon/ipc-contract.js';
|
|
12
|
+
import type {
|
|
13
|
+
NotificationChannel,
|
|
14
|
+
ChannelAdapter,
|
|
15
|
+
ChannelDeliveryPayload,
|
|
16
|
+
ChannelDestination,
|
|
17
|
+
DeliveryResult,
|
|
18
|
+
} from '../types.js';
|
|
19
|
+
|
|
20
|
+
const log = getLogger('notif-adapter-macos');
|
|
21
|
+
|
|
22
|
+
export type BroadcastFn = (msg: ServerMessage) => void;
|
|
23
|
+
|
|
24
|
+
export class MacOSAdapter implements ChannelAdapter {
|
|
25
|
+
readonly channel: NotificationChannel = 'macos';
|
|
26
|
+
|
|
27
|
+
private broadcast: BroadcastFn;
|
|
28
|
+
|
|
29
|
+
constructor(broadcast: BroadcastFn) {
|
|
30
|
+
this.broadcast = broadcast;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async send(payload: ChannelDeliveryPayload, _destination: ChannelDestination): Promise<DeliveryResult> {
|
|
34
|
+
try {
|
|
35
|
+
this.broadcast({
|
|
36
|
+
type: 'notification_intent',
|
|
37
|
+
sourceEventName: payload.sourceEventName,
|
|
38
|
+
title: payload.copy.title,
|
|
39
|
+
body: payload.copy.body,
|
|
40
|
+
deepLinkMetadata: payload.deepLinkTarget,
|
|
41
|
+
} as ServerMessage);
|
|
42
|
+
|
|
43
|
+
log.info(
|
|
44
|
+
{ sourceEventName: payload.sourceEventName, title: payload.copy.title },
|
|
45
|
+
'macOS notification intent broadcast',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { success: true };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
log.error({ err, sourceEventName: payload.sourceEventName }, 'Failed to broadcast macOS notification intent');
|
|
52
|
+
return { success: false, error: message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel adapter — delivers notifications to Telegram chats
|
|
3
|
+
* via the gateway's channel-reply endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Follows the same delivery pattern used by guardian-dispatch: POST to
|
|
6
|
+
* the gateway's `/deliver/telegram` endpoint with a chat ID and text
|
|
7
|
+
* payload. The gateway forwards the message to the Telegram Bot API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../../util/logger.js';
|
|
11
|
+
import { getGatewayInternalBaseUrl } from '../../config/env.js';
|
|
12
|
+
import { deliverChannelReply } from '../../runtime/gateway-client.js';
|
|
13
|
+
import { readHttpToken } from '../../util/platform.js';
|
|
14
|
+
import type {
|
|
15
|
+
NotificationChannel,
|
|
16
|
+
ChannelAdapter,
|
|
17
|
+
ChannelDeliveryPayload,
|
|
18
|
+
ChannelDestination,
|
|
19
|
+
DeliveryResult,
|
|
20
|
+
} from '../types.js';
|
|
21
|
+
|
|
22
|
+
const log = getLogger('notif-adapter-telegram');
|
|
23
|
+
|
|
24
|
+
export class TelegramAdapter implements ChannelAdapter {
|
|
25
|
+
readonly channel: NotificationChannel = 'telegram';
|
|
26
|
+
|
|
27
|
+
async send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult> {
|
|
28
|
+
const chatId = destination.endpoint;
|
|
29
|
+
if (!chatId) {
|
|
30
|
+
log.warn({ sourceEventName: payload.sourceEventName }, 'Telegram destination has no chat ID — skipping');
|
|
31
|
+
return { success: false, error: 'No chat ID configured for Telegram destination' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const gatewayBase = getGatewayInternalBaseUrl();
|
|
35
|
+
const deliverUrl = `${gatewayBase}/deliver/telegram`;
|
|
36
|
+
|
|
37
|
+
// Format copy for Telegram as plain text (no parse_mode set on gateway side)
|
|
38
|
+
let messageText = payload.copy.title + '\n\n' + payload.copy.body;
|
|
39
|
+
if (payload.copy.threadTitle) {
|
|
40
|
+
messageText += '\n\nThread: ' + payload.copy.threadTitle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await deliverChannelReply(
|
|
45
|
+
deliverUrl,
|
|
46
|
+
{ chatId, text: messageText },
|
|
47
|
+
readHttpToken() ?? undefined,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
log.info(
|
|
51
|
+
{ sourceEventName: payload.sourceEventName, chatId },
|
|
52
|
+
'Telegram notification delivered',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { success: true };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
log.error(
|
|
59
|
+
{ err, sourceEventName: payload.sourceEventName, chatId },
|
|
60
|
+
'Failed to deliver Telegram notification',
|
|
61
|
+
);
|
|
62
|
+
return { success: false, error: message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationBroadcaster -- dispatches a notification decision to all
|
|
3
|
+
* selected channels through their respective adapters.
|
|
4
|
+
*
|
|
5
|
+
* For each channel in the decision's selectedChannels:
|
|
6
|
+
* 1. Resolves the destination via the destination-resolver
|
|
7
|
+
* 2. Pulls rendered copy from the decision (or falls back to copy-composer)
|
|
8
|
+
* 3. Dispatches through the channel adapter
|
|
9
|
+
* 4. Records a delivery audit row in the deliveries-store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { v4 as uuid } from 'uuid';
|
|
13
|
+
import { getLogger } from '../util/logger.js';
|
|
14
|
+
import { composeFallbackCopy } from './copy-composer.js';
|
|
15
|
+
import { resolveDestinations } from './destination-resolver.js';
|
|
16
|
+
import { createDelivery, updateDeliveryStatus } from './deliveries-store.js';
|
|
17
|
+
import type { NotificationSignal } from './signal.js';
|
|
18
|
+
import type {
|
|
19
|
+
NotificationChannel,
|
|
20
|
+
NotificationDecision,
|
|
21
|
+
NotificationDeliveryResult,
|
|
22
|
+
ChannelAdapter,
|
|
23
|
+
ChannelDeliveryPayload,
|
|
24
|
+
RenderedChannelCopy,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
|
|
27
|
+
const log = getLogger('notif-broadcaster');
|
|
28
|
+
|
|
29
|
+
export class NotificationBroadcaster {
|
|
30
|
+
private adapters: Map<NotificationChannel, ChannelAdapter>;
|
|
31
|
+
|
|
32
|
+
constructor(adapters: ChannelAdapter[]) {
|
|
33
|
+
this.adapters = new Map();
|
|
34
|
+
for (const adapter of adapters) {
|
|
35
|
+
this.adapters.set(adapter.channel, adapter);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Broadcast a notification decision to all selected channels.
|
|
41
|
+
*
|
|
42
|
+
* The decision carries rendered copy per channel. When the decision was
|
|
43
|
+
* produced by the fallback path (fallbackUsed === true) and is missing
|
|
44
|
+
* copy for a channel, the copy-composer generates deterministic fallback copy.
|
|
45
|
+
*
|
|
46
|
+
* Returns an array of delivery results -- one per channel attempted.
|
|
47
|
+
*/
|
|
48
|
+
async broadcastDecision(
|
|
49
|
+
signal: NotificationSignal,
|
|
50
|
+
decision: NotificationDecision,
|
|
51
|
+
): Promise<NotificationDeliveryResult[]> {
|
|
52
|
+
const destinations = resolveDestinations(signal.assistantId, decision.selectedChannels);
|
|
53
|
+
|
|
54
|
+
// Pre-compute fallback copy in case any channel is missing rendered copy
|
|
55
|
+
let fallbackCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> | null = null;
|
|
56
|
+
|
|
57
|
+
const results: NotificationDeliveryResult[] = [];
|
|
58
|
+
|
|
59
|
+
for (const channel of decision.selectedChannels) {
|
|
60
|
+
const adapter = this.adapters.get(channel);
|
|
61
|
+
if (!adapter) {
|
|
62
|
+
log.warn({ channel, signalId: signal.signalId }, 'No adapter registered for channel -- skipping');
|
|
63
|
+
results.push({
|
|
64
|
+
channel,
|
|
65
|
+
destination: '',
|
|
66
|
+
status: 'skipped',
|
|
67
|
+
errorMessage: `No adapter for channel: ${channel}`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const destination = destinations.get(channel);
|
|
73
|
+
if (!destination) {
|
|
74
|
+
log.warn({ channel, signalId: signal.signalId }, 'Could not resolve destination -- skipping');
|
|
75
|
+
results.push({
|
|
76
|
+
channel,
|
|
77
|
+
destination: '',
|
|
78
|
+
status: 'skipped',
|
|
79
|
+
errorMessage: `Destination not resolved for channel: ${channel}`,
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pull rendered copy from the decision; fall back to copy-composer if missing
|
|
85
|
+
let copy = decision.renderedCopy[channel];
|
|
86
|
+
if (!copy) {
|
|
87
|
+
if (!fallbackCopy) {
|
|
88
|
+
fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
|
|
89
|
+
}
|
|
90
|
+
copy = fallbackCopy[channel] ?? { title: 'Notification', body: signal.sourceEventName };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const payload: ChannelDeliveryPayload = {
|
|
94
|
+
sourceEventName: signal.sourceEventName,
|
|
95
|
+
copy,
|
|
96
|
+
deepLinkTarget: decision.deepLinkTarget,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const deliveryId = uuid();
|
|
100
|
+
const destinationLabel = destination.endpoint ?? channel;
|
|
101
|
+
|
|
102
|
+
// Only create a delivery audit record when we have a persisted decision ID
|
|
103
|
+
// for the FK. If decision persistence failed (persistedDecisionId is
|
|
104
|
+
// undefined), we still dispatch via the adapter but skip the delivery
|
|
105
|
+
// record — using dedupeKey would violate the FK constraint.
|
|
106
|
+
const persistedDecisionId = decision.persistedDecisionId;
|
|
107
|
+
const hasPersistedDecision = typeof persistedDecisionId === 'string';
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (hasPersistedDecision) {
|
|
111
|
+
createDelivery({
|
|
112
|
+
id: deliveryId,
|
|
113
|
+
notificationDecisionId: persistedDecisionId,
|
|
114
|
+
assistantId: signal.assistantId,
|
|
115
|
+
channel,
|
|
116
|
+
destination: destinationLabel,
|
|
117
|
+
status: 'pending',
|
|
118
|
+
attempt: 1,
|
|
119
|
+
renderedTitle: copy.title,
|
|
120
|
+
renderedBody: copy.body,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
log.warn(
|
|
124
|
+
{ channel, signalId: signal.signalId },
|
|
125
|
+
'No persisted decision ID -- skipping delivery record creation',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const adapterResult = await adapter.send(payload, destination);
|
|
130
|
+
|
|
131
|
+
if (adapterResult.success) {
|
|
132
|
+
if (hasPersistedDecision) {
|
|
133
|
+
updateDeliveryStatus(deliveryId, 'sent');
|
|
134
|
+
}
|
|
135
|
+
results.push({
|
|
136
|
+
channel,
|
|
137
|
+
destination: destinationLabel,
|
|
138
|
+
status: 'sent',
|
|
139
|
+
sentAt: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
if (hasPersistedDecision) {
|
|
143
|
+
updateDeliveryStatus(deliveryId, 'failed', { message: adapterResult.error });
|
|
144
|
+
}
|
|
145
|
+
results.push({
|
|
146
|
+
channel,
|
|
147
|
+
destination: destinationLabel,
|
|
148
|
+
status: 'failed',
|
|
149
|
+
errorMessage: adapterResult.error,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
154
|
+
log.error({ err, channel, signalId: signal.signalId }, 'Unexpected error during channel delivery');
|
|
155
|
+
|
|
156
|
+
if (hasPersistedDecision) {
|
|
157
|
+
try {
|
|
158
|
+
updateDeliveryStatus(deliveryId, 'failed', { message: errorMessage });
|
|
159
|
+
} catch {
|
|
160
|
+
// Swallow -- the delivery record may not exist if createDelivery failed
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
results.push({
|
|
165
|
+
channel,
|
|
166
|
+
destination: destinationLabel,
|
|
167
|
+
status: 'failed',
|
|
168
|
+
errorMessage,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, template-based copy generation for notification deliveries.
|
|
3
|
+
*
|
|
4
|
+
* This is the fallback path used when the decision engine's LLM-generated
|
|
5
|
+
* copy is unavailable (fallbackUsed === true). It generates reasonable
|
|
6
|
+
* copy from the signal's sourceEventName, contextPayload, and attentionHints.
|
|
7
|
+
*
|
|
8
|
+
* Each source event name has a set of fallback templates that interpolate
|
|
9
|
+
* values from the context payload.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { NotificationSignal } from './signal.js';
|
|
13
|
+
import type { NotificationChannel, RenderedChannelCopy } from './types.js';
|
|
14
|
+
|
|
15
|
+
type CopyTemplate = (payload: Record<string, unknown>) => RenderedChannelCopy;
|
|
16
|
+
|
|
17
|
+
function str(value: unknown, fallback: string): string {
|
|
18
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Templates keyed by dot-separated sourceEventName strings matching producers.
|
|
23
|
+
const TEMPLATES: Record<string, CopyTemplate> = {
|
|
24
|
+
'reminder.fired': (payload) => ({
|
|
25
|
+
title: 'Reminder',
|
|
26
|
+
body: str(payload.message, str(payload.label, 'A reminder has fired')),
|
|
27
|
+
}),
|
|
28
|
+
|
|
29
|
+
'schedule.complete': (payload) => ({
|
|
30
|
+
title: 'Schedule Complete',
|
|
31
|
+
body: `${str(payload.name, 'A schedule')} has finished running`,
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
'guardian.question': (payload) => ({
|
|
35
|
+
title: 'Guardian Question',
|
|
36
|
+
body: str(payload.questionText, 'A guardian question needs your attention'),
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
'ingress.escalation': (payload) => ({
|
|
40
|
+
title: 'Escalation',
|
|
41
|
+
body: str(payload.senderIdentifier, 'An incoming message') + ' needs attention',
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
'watcher.notification': (payload) => ({
|
|
45
|
+
title: str(payload.title, 'Watcher Notification'),
|
|
46
|
+
body: str(payload.body, 'A watcher event occurred'),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
'watcher.escalation': (payload) => ({
|
|
50
|
+
title: str(payload.title, 'Watcher Escalation'),
|
|
51
|
+
body: str(payload.body, 'A watcher event requires your attention'),
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
'tool_confirmation.required_action': (payload) => ({
|
|
55
|
+
title: 'Tool Confirmation',
|
|
56
|
+
body: str(payload.toolName, 'A tool') + ' requires your confirmation',
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
'activity.complete': (payload) => ({
|
|
60
|
+
title: 'Activity Complete',
|
|
61
|
+
body: str(payload.summary, 'An activity has completed'),
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
'quick_chat.response_ready': (payload) => ({
|
|
65
|
+
title: 'Response Ready',
|
|
66
|
+
body: str(payload.preview, 'Your quick chat response is ready'),
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
'voice.response_ready': (payload) => ({
|
|
70
|
+
title: 'Voice Response',
|
|
71
|
+
body: str(payload.preview, 'A voice response is ready'),
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
'ride_shotgun.invitation': (payload) => ({
|
|
75
|
+
title: 'Ride Shotgun',
|
|
76
|
+
body: str(payload.message, 'You have been invited to ride shotgun'),
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compose fallback notification copy for a signal when the decision
|
|
82
|
+
* engine's LLM path is unavailable.
|
|
83
|
+
*
|
|
84
|
+
* Returns a map of channel -> RenderedChannelCopy for the requested channels.
|
|
85
|
+
* All channels currently receive the same template output; per-channel
|
|
86
|
+
* customisation can be layered on later.
|
|
87
|
+
*/
|
|
88
|
+
export function composeFallbackCopy(
|
|
89
|
+
signal: NotificationSignal,
|
|
90
|
+
channels: NotificationChannel[],
|
|
91
|
+
): Partial<Record<NotificationChannel, RenderedChannelCopy>> {
|
|
92
|
+
const template = TEMPLATES[signal.sourceEventName];
|
|
93
|
+
|
|
94
|
+
const baseCopy: RenderedChannelCopy = template
|
|
95
|
+
? template(signal.contextPayload)
|
|
96
|
+
: buildGenericCopy(signal);
|
|
97
|
+
|
|
98
|
+
const result: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
|
|
99
|
+
for (const ch of channels) {
|
|
100
|
+
result[ch] = { ...baseCopy };
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build generic copy when no template matches. Uses the signal's
|
|
107
|
+
* sourceEventName and attention hints to produce something reasonable.
|
|
108
|
+
*/
|
|
109
|
+
function buildGenericCopy(signal: NotificationSignal): RenderedChannelCopy {
|
|
110
|
+
const humanName = signal.sourceEventName.replace(/[._]/g, ' ');
|
|
111
|
+
const urgencyPrefix = signal.attentionHints.urgency === 'high' ? 'Urgent: ' : '';
|
|
112
|
+
const actionSuffix = signal.attentionHints.requiresAction ? ' — action required' : '';
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
title: 'Notification',
|
|
116
|
+
body: `${urgencyPrefix}${humanName}${actionSuffix}`,
|
|
117
|
+
};
|
|
118
|
+
}
|