@vellumai/assistant 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  11. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  12. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  13. package/src/calls/call-domain.ts +21 -21
  14. package/src/calls/call-state.ts +12 -12
  15. package/src/calls/guardian-dispatch.ts +43 -3
  16. package/src/calls/relay-server.ts +34 -39
  17. package/src/calls/twilio-routes.ts +3 -3
  18. package/src/calls/voice-session-bridge.ts +244 -0
  19. package/src/config/defaults.ts +5 -0
  20. package/src/config/notifications-schema.ts +15 -0
  21. package/src/config/schema.ts +13 -0
  22. package/src/config/types.ts +1 -0
  23. package/src/daemon/ipc-contract/notifications.ts +9 -0
  24. package/src/daemon/ipc-contract-inventory.json +2 -0
  25. package/src/daemon/ipc-contract.ts +4 -1
  26. package/src/daemon/lifecycle.ts +84 -1
  27. package/src/daemon/session-agent-loop.ts +4 -0
  28. package/src/daemon/session-process.ts +51 -0
  29. package/src/daemon/session-runtime-assembly.ts +32 -0
  30. package/src/daemon/session.ts +5 -0
  31. package/src/memory/db-init.ts +80 -0
  32. package/src/memory/guardian-action-store.ts +2 -2
  33. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  34. package/src/memory/migrations/index.ts +1 -0
  35. package/src/memory/migrations/registry.ts +5 -0
  36. package/src/memory/schema-migration.ts +1 -0
  37. package/src/memory/schema.ts +59 -0
  38. package/src/notifications/README.md +134 -0
  39. package/src/notifications/adapters/macos.ts +55 -0
  40. package/src/notifications/adapters/telegram.ts +65 -0
  41. package/src/notifications/broadcaster.ts +175 -0
  42. package/src/notifications/copy-composer.ts +118 -0
  43. package/src/notifications/decision-engine.ts +391 -0
  44. package/src/notifications/decisions-store.ts +158 -0
  45. package/src/notifications/deliveries-store.ts +130 -0
  46. package/src/notifications/destination-resolver.ts +54 -0
  47. package/src/notifications/deterministic-checks.ts +187 -0
  48. package/src/notifications/emit-signal.ts +191 -0
  49. package/src/notifications/events-store.ts +145 -0
  50. package/src/notifications/preference-extractor.ts +223 -0
  51. package/src/notifications/preference-summary.ts +110 -0
  52. package/src/notifications/preferences-store.ts +142 -0
  53. package/src/notifications/runtime-dispatch.ts +100 -0
  54. package/src/notifications/signal.ts +24 -0
  55. package/src/notifications/types.ts +75 -0
  56. package/src/runtime/http-server.ts +10 -0
  57. package/src/runtime/pending-interactions.ts +73 -0
  58. package/src/runtime/routes/approval-routes.ts +179 -0
  59. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  60. package/src/runtime/routes/conversation-routes.ts +31 -1
  61. package/src/runtime/routes/run-routes.ts +1 -1
  62. package/src/runtime/run-orchestrator.ts +157 -2
  63. package/src/tools/browser/browser-manager.ts +1 -1
  64. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+
3
+ export const NotificationsConfigSchema = z.object({
4
+ enabled: z
5
+ .boolean({ error: 'notifications.enabled must be a boolean' })
6
+ .default(false),
7
+ shadowMode: z
8
+ .boolean({ error: 'notifications.shadowMode must be a boolean' })
9
+ .default(true),
10
+ decisionModel: z
11
+ .string({ error: 'notifications.decisionModel must be a string' })
12
+ .default('claude-haiku-4-5-20251001'),
13
+ });
14
+
15
+ export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>;
@@ -90,6 +90,13 @@ export type {
90
90
  WorkspaceGitConfig,
91
91
  } from './agent-schema.js';
92
92
 
93
+ export {
94
+ NotificationsConfigSchema,
95
+ } from './notifications-schema.js';
96
+ export type {
97
+ NotificationsConfig,
98
+ } from './notifications-schema.js';
99
+
93
100
  export {
94
101
  TimeoutConfigSchema,
95
102
  RateLimitConfigSchema,
@@ -129,6 +136,7 @@ import { CallsConfigSchema } from './calls-schema.js';
129
136
  import { SandboxConfigSchema } from './sandbox-schema.js';
130
137
  import { SkillsConfigSchema } from './skills-schema.js';
131
138
  import { AgentHeartbeatConfigSchema, SwarmConfigSchema, WorkspaceGitConfigSchema } from './agent-schema.js';
139
+ import { NotificationsConfigSchema } from './notifications-schema.js';
132
140
  import {
133
141
  TimeoutConfigSchema,
134
142
  RateLimitConfigSchema,
@@ -437,6 +445,11 @@ export const AssistantConfigSchema = z.object({
437
445
  sigkillGracePeriodMs: 2000,
438
446
  titleGenerationMaxTokens: 30,
439
447
  }),
448
+ notifications: NotificationsConfigSchema.default({
449
+ enabled: false,
450
+ shadowMode: true,
451
+ decisionModel: 'claude-haiku-4-5-20251001',
452
+ }),
440
453
  }).superRefine((config, ctx) => {
441
454
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
442
455
  ctx.addIssue({
@@ -39,5 +39,6 @@ export type {
39
39
  CallerIdentityConfig,
40
40
  SmsConfig,
41
41
  IngressConfig,
42
+ NotificationsConfig,
42
43
  DaemonConfig,
43
44
  } from './schema.js';
@@ -0,0 +1,9 @@
1
+ /** Broadcast to connected macOS clients when a notification should be displayed. */
2
+ export interface NotificationIntent {
3
+ type: 'notification_intent';
4
+ sourceEventName: string;
5
+ title: string;
6
+ body: string;
7
+ /** Optional deep-link metadata so the client can navigate to the relevant context. */
8
+ deepLinkMetadata?: Record<string, unknown>;
9
+ }
@@ -201,6 +201,7 @@
201
201
  "MessageQueued",
202
202
  "MessageQueuedDeleted",
203
203
  "ModelInfo",
204
+ "NotificationIntent",
204
205
  "OpenBundleResponse",
205
206
  "OpenTasksWindow",
206
207
  "OpenUrl",
@@ -486,6 +487,7 @@
486
487
  "message_queued",
487
488
  "message_queued_deleted",
488
489
  "model_info",
490
+ "notification_intent",
489
491
  "open_bundle_response",
490
492
  "open_tasks_window",
491
493
  "open_url",
@@ -27,6 +27,7 @@ export * from './ipc-contract/diagnostics.js';
27
27
  export * from './ipc-contract/parental-control.js';
28
28
  export * from './ipc-contract/inbox.js';
29
29
  export * from './ipc-contract/pairing.js';
30
+ export * from './ipc-contract/notifications.js';
30
31
 
31
32
  // Import types needed for aggregate unions and SubagentEvent
32
33
  import type { AuthMessage, PingMessage, CancelRequest, DeleteQueuedMessage, ModelGetRequest, ModelSetRequest, ImageGenModelSetRequest, HistoryRequest, UndoRequest, RegenerateRequest, UsageRequest, SandboxSetRequest, SessionListRequest, SessionCreateRequest, SessionSwitchRequest, SessionRenameRequest, SessionsClearRequest, ConversationSearchRequest } from './ipc-contract/sessions.js';
@@ -64,6 +65,7 @@ import type { SchedulesList, ScheduleToggle, ScheduleRemove, ScheduleRunNow, Rem
64
65
  import type { ParentalControlGetRequest, ParentalControlVerifyPinRequest, ParentalControlSetPinRequest, ParentalControlUpdateRequest, ParentalControlGetResponse, ParentalControlVerifyPinResponse, ParentalControlSetPinResponse, ParentalControlUpdateResponse } from './ipc-contract/parental-control.js';
65
66
  import type { IngressInviteRequest, IngressMemberRequest, AssistantInboxRequest, AssistantInboxEscalationRequest, AssistantInboxReplyRequest, IngressInviteResponse, IngressMemberResponse, AssistantInboxResponse, AssistantInboxEscalationResponse, AssistantInboxReplyResponse } from './ipc-contract/inbox.js';
66
67
  import type { PairingApprovalResponse, ApprovedDevicesList, ApprovedDeviceRemove, ApprovedDevicesClear, PairingApprovalRequest, ApprovedDevicesListResponse, ApprovedDeviceRemoveResponse } from './ipc-contract/pairing.js';
68
+ import type { NotificationIntent } from './ipc-contract/notifications.js';
67
69
 
68
70
  // === SubagentEvent — defined here because it references ServerMessage ===
69
71
 
@@ -361,7 +363,8 @@ export type ServerMessage =
361
363
  | AssistantInboxReplyResponse
362
364
  | PairingApprovalRequest
363
365
  | ApprovedDevicesListResponse
364
- | ApprovedDeviceRemoveResponse;
366
+ | ApprovedDeviceRemoveResponse
367
+ | NotificationIntent;
365
368
 
366
369
  // === Contract schema ===
367
370
 
@@ -26,6 +26,7 @@ import { ensurePromptFiles } from '../config/system-prompt.js';
26
26
  import { loadPrebuiltHtml } from '../home-base/prebuilt/seed.js';
27
27
  import { DaemonServer } from './server.js';
28
28
  import { setRelayBroadcast } from '../calls/relay-server.js';
29
+ import { setVoiceBridgeOrchestrator } from '../calls/voice-session-bridge.js';
29
30
  import { listWorkItems, updateWorkItem } from '../work-items/work-item-store.js';
30
31
  import { getLogger, initLogger } from '../util/logger.js';
31
32
  import { initSentry } from '../instrument.js';
@@ -44,6 +45,7 @@ import { HeartbeatService } from '../workspace/heartbeat-service.js';
44
45
  import { AgentHeartbeatService } from '../agent-heartbeat/agent-heartbeat-service.js';
45
46
  import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
46
47
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
48
+ import { emitNotificationSignal, registerBroadcastFn } from '../notifications/emit-signal.js';
47
49
  import { createApprovalCopyGenerator, createApprovalConversationGenerator } from './approval-generators.js';
48
50
  import { initializeProvidersAndTools, registerWatcherProviders, registerMessagingProviders } from './providers-setup.js';
49
51
  import { installShutdownHandlers } from './shutdown-handlers.js';
@@ -187,38 +189,112 @@ export async function runDaemon(): Promise<void> {
187
189
  registerWatcherProviders();
188
190
  registerMessagingProviders();
189
191
 
192
+ // Register the IPC broadcast function for the notification signal pipeline's
193
+ // macOS adapter so it can deliver notification_intent messages to desktop clients.
194
+ registerBroadcastFn((msg) => server.broadcast(msg));
195
+
190
196
  const scheduler = startScheduler(
191
197
  async (conversationId, message) => {
192
198
  await server.processMessage(conversationId, message);
193
199
  },
194
200
  (reminder) => {
201
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
195
202
  server.broadcast({
196
203
  type: 'reminder_fired',
197
204
  reminderId: reminder.id,
198
205
  label: reminder.label,
199
206
  message: reminder.message,
200
207
  });
208
+ // Signal pipeline: fire-and-forget
209
+ void emitNotificationSignal({
210
+ sourceEventName: 'reminder.fired',
211
+ sourceChannel: 'scheduler',
212
+ sourceSessionId: reminder.id,
213
+ attentionHints: {
214
+ requiresAction: true,
215
+ urgency: 'high',
216
+ isAsyncBackground: false,
217
+ visibleInSourceNow: false,
218
+ },
219
+ contextPayload: {
220
+ reminderId: reminder.id,
221
+ label: reminder.label,
222
+ message: reminder.message,
223
+ },
224
+ dedupeKey: `reminder:${reminder.id}`,
225
+ });
201
226
  },
202
227
  (schedule) => {
228
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
203
229
  server.broadcast({
204
230
  type: 'schedule_complete',
205
231
  scheduleId: schedule.id,
206
232
  name: schedule.name,
207
233
  });
234
+ // Signal pipeline: fire-and-forget
235
+ void emitNotificationSignal({
236
+ sourceEventName: 'schedule.complete',
237
+ sourceChannel: 'scheduler',
238
+ sourceSessionId: schedule.id,
239
+ attentionHints: {
240
+ requiresAction: false,
241
+ urgency: 'medium',
242
+ isAsyncBackground: true,
243
+ visibleInSourceNow: false,
244
+ },
245
+ contextPayload: {
246
+ scheduleId: schedule.id,
247
+ name: schedule.name,
248
+ },
249
+ });
208
250
  },
209
251
  (notification) => {
252
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
210
253
  server.broadcast({
211
254
  type: 'watcher_notification',
212
255
  title: notification.title,
213
256
  body: notification.body,
214
257
  });
258
+ // Signal pipeline: fire-and-forget
259
+ void emitNotificationSignal({
260
+ sourceEventName: 'watcher.notification',
261
+ sourceChannel: 'watcher',
262
+ sourceSessionId: `watcher-${Date.now()}`,
263
+ attentionHints: {
264
+ requiresAction: false,
265
+ urgency: 'low',
266
+ isAsyncBackground: true,
267
+ visibleInSourceNow: false,
268
+ },
269
+ contextPayload: {
270
+ title: notification.title,
271
+ body: notification.body,
272
+ },
273
+ });
215
274
  },
216
275
  (params) => {
276
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
217
277
  server.broadcast({
218
278
  type: 'watcher_escalation',
219
279
  title: params.title,
220
280
  body: params.body,
221
281
  });
282
+ // Signal pipeline: fire-and-forget
283
+ void emitNotificationSignal({
284
+ sourceEventName: 'watcher.escalation',
285
+ sourceChannel: 'watcher',
286
+ sourceSessionId: `watcher-escalation-${Date.now()}`,
287
+ attentionHints: {
288
+ requiresAction: true,
289
+ urgency: 'high',
290
+ isAsyncBackground: false,
291
+ visibleInSourceNow: false,
292
+ },
293
+ contextPayload: {
294
+ title: params.title,
295
+ body: params.body,
296
+ },
297
+ });
222
298
  },
223
299
  );
224
300
 
@@ -250,6 +326,8 @@ export async function runDaemon(): Promise<void> {
250
326
 
251
327
  const hostname = getRuntimeHttpHost();
252
328
 
329
+ const runOrchestrator = server.createRunOrchestrator();
330
+
253
331
  runtimeHttp = new RuntimeHttpServer({
254
332
  port: httpPort,
255
333
  hostname,
@@ -258,7 +336,7 @@ export async function runDaemon(): Promise<void> {
258
336
  server.processMessage(conversationId, content, attachmentIds, options, sourceChannel),
259
337
  persistAndProcessMessage: (conversationId, content, attachmentIds, options, sourceChannel) =>
260
338
  server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel),
261
- runOrchestrator: server.createRunOrchestrator(),
339
+ runOrchestrator,
262
340
  interfacesDir: getInterfacesDir(),
263
341
  approvalCopyGenerator: createApprovalCopyGenerator(),
264
342
  approvalConversationGenerator: createApprovalConversationGenerator(),
@@ -275,6 +353,11 @@ export async function runDaemon(): Promise<void> {
275
353
  })),
276
354
  },
277
355
  });
356
+
357
+ // Inject the voice bridge orchestrator BEFORE attempting to start the HTTP
358
+ // server. The bridge only needs the RunOrchestrator instance (already created
359
+ // above) and must be available even when the HTTP server fails to bind.
360
+ setVoiceBridgeOrchestrator(runOrchestrator);
278
361
  try {
279
362
  await runtimeHttp.start();
280
363
  setRelayBroadcast((msg) => server.broadcast(msg));
@@ -100,6 +100,7 @@ export interface AgentLoopSessionContext {
100
100
  channelCapabilities?: ChannelCapabilities;
101
101
  commandIntent?: { type: string; payload?: string; languageCode?: string };
102
102
  guardianContext?: GuardianRuntimeContext;
103
+ voiceCallControlPrompt?: string;
103
104
 
104
105
  readonly coreToolNames: Set<string>;
105
106
  allowedToolNames?: Set<string>;
@@ -321,6 +322,7 @@ export async function runAgentLoopImpl(
321
322
  channelTurnContext,
322
323
  guardianContext: ctx.guardianContext ?? null,
323
324
  temporalContext,
325
+ voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
324
326
  });
325
327
 
326
328
  // Pre-run repair
@@ -431,6 +433,7 @@ export async function runAgentLoopImpl(
431
433
  channelTurnContext,
432
434
  guardianContext: ctx.guardianContext ?? null,
433
435
  temporalContext,
436
+ voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
434
437
  });
435
438
  preRepairMessages = runMessages;
436
439
  preRunHistoryLength = runMessages.length;
@@ -466,6 +469,7 @@ export async function runAgentLoopImpl(
466
469
  channelTurnContext,
467
470
  guardianContext: ctx.guardianContext ?? null,
468
471
  temporalContext,
472
+ voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
469
473
  });
470
474
  preRepairMessages = runMessages;
471
475
  preRunHistoryLength = runMessages.length;
@@ -24,6 +24,8 @@ import {
24
24
  import { answerCall } from '../calls/call-domain.js';
25
25
  import { resolveSlash, type SlashContext } from './session-slash.js';
26
26
  import { getConfig } from '../config/loader.js';
27
+ import { extractPreferences } from '../notifications/preference-extractor.js';
28
+ import { createPreference } from '../notifications/preferences-store.js';
27
29
  import { getLogger } from '../util/logger.js';
28
30
 
29
31
  const log = getLogger('session-process');
@@ -68,6 +70,8 @@ export interface ProcessSessionContext {
68
70
  readonly usageStats: UsageStats;
69
71
  /** Request-scoped skill IDs preactivated via slash resolution. */
70
72
  preactivatedSkillIds?: string[];
73
+ /** Assistant identity — used for scoping notification preferences. */
74
+ readonly assistantId?: string;
71
75
  persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>): string;
72
76
  runAgentLoop(
73
77
  content: string,
@@ -231,6 +235,29 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
231
235
  session.currentActiveSurfaceId = next.activeSurfaceId;
232
236
  session.currentPage = next.currentPage;
233
237
 
238
+ // Fire-and-forget: detect notification preferences in the queued message
239
+ // and persist any that are found, mirroring the logic in processMessage.
240
+ if (session.assistantId && getConfig().notifications.enabled) {
241
+ const aid = session.assistantId;
242
+ extractPreferences(resolvedContent)
243
+ .then((result) => {
244
+ if (!result.detected) return;
245
+ for (const pref of result.preferences) {
246
+ createPreference({
247
+ assistantId: aid,
248
+ preferenceText: pref.preferenceText,
249
+ appliesWhen: pref.appliesWhen,
250
+ priority: pref.priority,
251
+ });
252
+ }
253
+ log.info({ count: result.preferences.length, conversationId: session.conversationId }, 'Persisted extracted notification preferences (queued)');
254
+ })
255
+ .catch((err) => {
256
+ const errMsg = err instanceof Error ? err.message : String(err);
257
+ log.warn({ err: errMsg, conversationId: session.conversationId }, 'Background preference extraction failed (queued)');
258
+ });
259
+ }
260
+
234
261
  // Fire-and-forget: persistUserMessage set session.processing = true
235
262
  // so subsequent messages will still be enqueued.
236
263
  // runAgentLoop's finally block will call drainQueue when this run completes.
@@ -375,6 +402,30 @@ export async function processMessage(
375
402
  return '';
376
403
  }
377
404
 
405
+ // Fire-and-forget: detect notification preferences in the user message
406
+ // and persist any that are found. Runs in the background so it doesn't
407
+ // block the main conversation flow.
408
+ if (session.assistantId && getConfig().notifications.enabled) {
409
+ const aid = session.assistantId;
410
+ extractPreferences(resolvedContent)
411
+ .then((result) => {
412
+ if (!result.detected) return;
413
+ for (const pref of result.preferences) {
414
+ createPreference({
415
+ assistantId: aid,
416
+ preferenceText: pref.preferenceText,
417
+ appliesWhen: pref.appliesWhen,
418
+ priority: pref.priority,
419
+ });
420
+ }
421
+ log.info({ count: result.preferences.length, conversationId: session.conversationId }, 'Persisted extracted notification preferences');
422
+ })
423
+ .catch((err) => {
424
+ const errMsg = err instanceof Error ? err.message : String(err);
425
+ log.warn({ err: errMsg, conversationId: session.conversationId }, 'Background preference extraction failed');
426
+ });
427
+ }
428
+
378
429
  await session.runAgentLoop(resolvedContent, userMessageId, onEvent);
379
430
  return userMessageId;
380
431
  }
@@ -261,6 +261,26 @@ export function injectActiveSurfaceContext(message: Message, ctx: ActiveSurfaceC
261
261
  };
262
262
  }
263
263
 
264
+ /**
265
+ * Append voice call-control protocol instructions to the last user
266
+ * message so the model knows how to emit control markers during voice
267
+ * turns routed through the session pipeline.
268
+ */
269
+ export function injectVoiceCallControlContext(message: Message, prompt: string): Message {
270
+ return {
271
+ ...message,
272
+ content: [
273
+ ...message.content,
274
+ { type: 'text', text: prompt },
275
+ ],
276
+ };
277
+ }
278
+
279
+ /** Strip `<voice_call_control>` blocks injected by `injectVoiceCallControlContext`. */
280
+ export function stripVoiceCallControlContext(messages: Message[]): Message[] {
281
+ return stripUserTextBlocksByPrefix(messages, ['<voice_call_control>']);
282
+ }
283
+
264
284
  /**
265
285
  * Prepend channel capability context to the last user message so the
266
286
  * model knows what the current channel can and cannot do.
@@ -514,6 +534,7 @@ const RUNTIME_INJECTION_PREFIXES = [
514
534
  '<channel_command_context>',
515
535
  '<channel_turn_context>',
516
536
  '<guardian_context>',
537
+ '<voice_call_control>',
517
538
  '<workspace_top_level>',
518
539
  TEMPORAL_INJECTED_PREFIX,
519
540
  '<active_workspace>',
@@ -558,10 +579,21 @@ export function applyRuntimeInjections(
558
579
  channelTurnContext?: ChannelTurnContextParams | null;
559
580
  guardianContext?: GuardianRuntimeContext | null;
560
581
  temporalContext?: string | null;
582
+ voiceCallControlPrompt?: string | null;
561
583
  },
562
584
  ): Message[] {
563
585
  let result = runMessages;
564
586
 
587
+ if (options.voiceCallControlPrompt) {
588
+ const userTail = result[result.length - 1];
589
+ if (userTail && userTail.role === 'user') {
590
+ result = [
591
+ ...result.slice(0, -1),
592
+ injectVoiceCallControlContext(userTail, options.voiceCallControlPrompt),
593
+ ];
594
+ }
595
+ }
596
+
565
597
  if (options.softConflictInstruction) {
566
598
  const userTail = result[result.length - 1];
567
599
  if (userTail && userTail.role === 'user') {
@@ -130,6 +130,7 @@ export class Session {
130
130
  /** @internal */ currentPage?: string;
131
131
  /** @internal */ channelCapabilities?: ChannelCapabilities;
132
132
  /** @internal */ guardianContext?: GuardianRuntimeContext;
133
+ /** @internal */ voiceCallControlPrompt?: string;
133
134
  /** @internal */ assistantId?: string;
134
135
  /** @internal */ commandIntent?: { type: string; payload?: string; languageCode?: string };
135
136
  /** @internal */ pendingSurfaceActions = new Map<string, { surfaceType: SurfaceType }>();
@@ -344,6 +345,10 @@ export class Session {
344
345
  this.guardianContext = ctx ?? undefined;
345
346
  }
346
347
 
348
+ setVoiceCallControlPrompt(prompt: string | null): void {
349
+ this.voiceCallControlPrompt = prompt ?? undefined;
350
+ }
351
+
347
352
  setAssistantId(assistantId: string | null): void {
348
353
  this.assistantId = assistantId ?? undefined;
349
354
  }
@@ -16,6 +16,7 @@ import {
16
16
  migrateGuardianActionTables,
17
17
  migrateBackfillInboxThreadStateFromBindings,
18
18
  migrateDropActiveSearchIndex,
19
+ migrateNotificationTablesSchema,
19
20
  migrateMemorySegmentsIndexes,
20
21
  migrateMemoryItemsIndexes,
21
22
  migrateRemainingTableIndexes,
@@ -1276,5 +1277,84 @@ export function initializeDb(): void {
1276
1277
 
1277
1278
  migrateRemainingTableIndexes(database);
1278
1279
 
1280
+ // ── Notification System ──────────────────────────────────────────────
1281
+
1282
+ // Migration: drop legacy enum-based notification tables if old schema detected.
1283
+ // Guarded behind a one-time check for the old `notification_type` column.
1284
+ migrateNotificationTablesSchema(database);
1285
+
1286
+ database.run(/*sql*/ `
1287
+ CREATE TABLE IF NOT EXISTS notification_preferences (
1288
+ id TEXT PRIMARY KEY,
1289
+ assistant_id TEXT NOT NULL,
1290
+ preference_text TEXT NOT NULL,
1291
+ applies_when_json TEXT NOT NULL DEFAULT '{}',
1292
+ priority INTEGER NOT NULL DEFAULT 0,
1293
+ created_at INTEGER NOT NULL,
1294
+ updated_at INTEGER NOT NULL
1295
+ )
1296
+ `);
1297
+
1298
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`);
1299
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`);
1300
+
1301
+ database.run(/*sql*/ `
1302
+ CREATE TABLE IF NOT EXISTS notification_events (
1303
+ id TEXT PRIMARY KEY,
1304
+ assistant_id TEXT NOT NULL,
1305
+ source_event_name TEXT NOT NULL,
1306
+ source_channel TEXT NOT NULL,
1307
+ source_session_id TEXT NOT NULL,
1308
+ attention_hints_json TEXT NOT NULL DEFAULT '{}',
1309
+ payload_json TEXT NOT NULL DEFAULT '{}',
1310
+ dedupe_key TEXT,
1311
+ created_at INTEGER NOT NULL,
1312
+ updated_at INTEGER NOT NULL
1313
+ )
1314
+ `);
1315
+
1316
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`);
1317
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`);
1318
+
1319
+ database.run(/*sql*/ `
1320
+ CREATE TABLE IF NOT EXISTS notification_decisions (
1321
+ id TEXT PRIMARY KEY,
1322
+ notification_event_id TEXT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,
1323
+ should_notify INTEGER NOT NULL,
1324
+ selected_channels TEXT NOT NULL DEFAULT '[]',
1325
+ reasoning_summary TEXT NOT NULL,
1326
+ confidence REAL NOT NULL,
1327
+ fallback_used INTEGER NOT NULL DEFAULT 0,
1328
+ prompt_version TEXT,
1329
+ validation_results TEXT,
1330
+ created_at INTEGER NOT NULL
1331
+ )
1332
+ `);
1333
+
1334
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_decisions_event_id ON notification_decisions(notification_event_id)`);
1335
+
1336
+ database.run(/*sql*/ `
1337
+ CREATE TABLE IF NOT EXISTS notification_deliveries (
1338
+ id TEXT PRIMARY KEY,
1339
+ notification_decision_id TEXT NOT NULL REFERENCES notification_decisions(id) ON DELETE CASCADE,
1340
+ assistant_id TEXT NOT NULL,
1341
+ channel TEXT NOT NULL,
1342
+ destination TEXT NOT NULL,
1343
+ status TEXT NOT NULL DEFAULT 'pending',
1344
+ attempt INTEGER NOT NULL DEFAULT 1,
1345
+ rendered_title TEXT,
1346
+ rendered_body TEXT,
1347
+ error_code TEXT,
1348
+ error_message TEXT,
1349
+ sent_at INTEGER,
1350
+ created_at INTEGER NOT NULL,
1351
+ updated_at INTEGER NOT NULL
1352
+ )
1353
+ `);
1354
+
1355
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_unique ON notification_deliveries(notification_decision_id, channel, destination, attempt)`);
1356
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_decision_id ON notification_deliveries(notification_decision_id)`);
1357
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`)
1358
+
1279
1359
  validateMigrationState(database);
1280
1360
  }
@@ -7,7 +7,7 @@
7
7
  * answer resolves the request and all other deliveries are marked answered.
8
8
  */
9
9
 
10
- import { and, eq, lt, inArray } from 'drizzle-orm';
10
+ import { and, eq, inArray, lt } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
  import { getDb, rawChanges } from './db.js';
13
13
  import {
@@ -337,7 +337,7 @@ export function createGuardianActionDelivery(params: {
337
337
  }
338
338
 
339
339
  /**
340
- * Look up pending deliveries for a specific destination.
340
+ * Look up sent deliveries for a specific destination.
341
341
  * Used by inbound message routing to match incoming answers to deliveries.
342
342
  */
343
343
  export function getPendingDeliveriesByDestination(
@@ -0,0 +1,70 @@
1
+ import { getSqliteFrom, type DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * One-time migration: drop legacy enum-based notification tables so they can
5
+ * be recreated with the new signal-contract schema.
6
+ *
7
+ * Guard: only runs when the old `notification_type` column exists on the
8
+ * `notification_events` table (the old enum-based schema). On fresh installs
9
+ * the table either doesn't exist yet or was created with the new schema, so
10
+ * CREATE TABLE IF NOT EXISTS in db-init handles idempotent creation.
11
+ *
12
+ * Drop order matters because of FK references:
13
+ * notification_deliveries -> notification_events (old schema FK)
14
+ * notification_decisions -> notification_events (new schema FK, may exist from partial upgrade)
15
+ * notification_events (the table being rebuilt)
16
+ * notification_preferences (fully removed, replaced by decision engine)
17
+ */
18
+ export function migrateNotificationTablesSchema(database: DrizzleDb): void {
19
+ const raw = getSqliteFrom(database);
20
+ const checkpointKey = 'migration_notification_tables_schema_v1';
21
+ const checkpoint = raw.query(
22
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
23
+ ).get(checkpointKey);
24
+ if (checkpoint) return;
25
+
26
+ // Check if the old schema is present: the legacy notification_events table
27
+ // had a `notification_type` column that the new schema does not.
28
+ const hasOldSchema = raw.query(
29
+ `SELECT COUNT(*) as cnt FROM pragma_table_info('notification_events') WHERE name = 'notification_type'`,
30
+ ).get() as { cnt: number } | undefined;
31
+
32
+ if (hasOldSchema && hasOldSchema.cnt > 0) {
33
+ try {
34
+ raw.exec('BEGIN');
35
+
36
+ // Drop in FK-safe order: children before parents
37
+ raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_deliveries`);
38
+ raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_decisions`);
39
+ raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_events`);
40
+ raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_preferences`);
41
+
42
+ raw.query(
43
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
44
+ ).run(checkpointKey, Date.now());
45
+
46
+ raw.exec('COMMIT');
47
+ } catch (e) {
48
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
49
+ throw e;
50
+ }
51
+ } else {
52
+ // No old schema detected (fresh install or already migrated).
53
+ // Still clean up notification_preferences if it exists, since we want
54
+ // to ensure it's removed regardless.
55
+ try {
56
+ raw.exec('BEGIN');
57
+
58
+ raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_preferences`);
59
+
60
+ raw.query(
61
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
62
+ ).run(checkpointKey, Date.now());
63
+
64
+ raw.exec('COMMIT');
65
+ } catch (e) {
66
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
67
+ throw e;
68
+ }
69
+ }
70
+ }
@@ -22,3 +22,4 @@ export { migrateDropActiveSearchIndex } from './015-drop-active-search-index.js'
22
22
  export { migrateMemorySegmentsIndexes } from './016-memory-segments-indexes.js';
23
23
  export { migrateMemoryItemsIndexes } from './017-memory-items-indexes.js';
24
24
  export { migrateRemainingTableIndexes } from './018-remaining-table-indexes.js';
25
+ export { migrateNotificationTablesSchema } from './019-notification-tables-schema-migration.js';
@@ -69,6 +69,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
69
69
  version: 9,
70
70
  description: 'Drop old idx_memory_items_active_search so it can be recreated with updated covering columns',
71
71
  },
72
+ {
73
+ key: 'migration_notification_tables_schema_v1',
74
+ version: 10,
75
+ description: 'Drop legacy enum-based notification tables so they can be recreated with the new signal-contract schema',
76
+ },
72
77
  ];
73
78
 
74
79
  export interface MigrationValidationResult {
@@ -19,6 +19,7 @@ export {
19
19
  migrateGuardianActionTables,
20
20
  migrateBackfillInboxThreadStateFromBindings,
21
21
  migrateDropActiveSearchIndex,
22
+ migrateNotificationTablesSchema,
22
23
  migrateMemorySegmentsIndexes,
23
24
  migrateMemoryItemsIndexes,
24
25
  migrateRemainingTableIndexes,