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