@vellumai/assistant 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -34,11 +34,11 @@ Preprocess a video asset: detect dead time via mpdecimate, segment the video int
34
34
 
35
35
  Parameters:
36
36
  - `asset_id` (required) — ID of the media asset.
37
- - `interval_seconds` — Interval between keyframes (default: 3s).
38
- - `segment_duration` — Duration of each segment window (default: 20s).
37
+ - `interval_seconds` — Interval between keyframes (default: 1s). Use 0.5s for sports/action content where frame density matters.
38
+ - `segment_duration` — Duration of each segment window (default: 15s).
39
39
  - `dead_time_threshold` — Sensitivity for dead-time detection (default: 0.02).
40
40
  - `section_config` — Path to a JSON file with manual section boundaries.
41
- - `skip_dead_time` — Whether to detect and skip dead time (default: true).
41
+ - `skip_dead_time` — Whether to detect and skip dead time (default: false). Dead-time detection can be too aggressive for continuous action video like sports — it may incorrectly skip live play. Enable only for content with clear idle periods (e.g., lectures, surveillance footage).
42
42
  - `short_edge` — Short edge resolution for downscaled frames in pixels (default: 480).
43
43
 
44
44
  ### analyze_keyframes
@@ -74,7 +74,7 @@ Get a diagnostic report for a media asset. Returns:
74
74
  - **Processing stats**: total keyframes extracted.
75
75
  - **Per-stage status and timing**: which stages (preprocess, map, reduce) have run, how long each took, current progress.
76
76
  - **Failure reasons**: last error from any failed stage.
77
- - **Cost estimation**: based on segment count and Gemini 2.5 Flash pricing, plus a note about Claude reduce costs.
77
+ - **Cost estimation**: based on segment count and current Gemini pricing.
78
78
 
79
79
  ## Services
80
80
 
@@ -110,6 +110,82 @@ Limits concurrent API calls during the Map phase to avoid rate limiting.
110
110
 
111
111
  Tracks estimated API costs during pipeline execution.
112
112
 
113
+ ## Best Practices
114
+
115
+ ### Map Prompt Strategy: Go Broad, Not Targeted
116
+
117
+ The single most important insight: **always use a broad, descriptive map prompt** instead of a targeted one.
118
+
119
+ A targeted prompt like "find turnovers" locks you into one topic. If the user later wants to ask about defense, formations, or specific players, you'd need to reprocess the entire video. Instead, run a general-purpose descriptive prompt that captures everything visible, creating a rich, reusable dataset. Then all follow-up questions can be handled via `query_media` with no reprocessing.
120
+
121
+ **One map run, many queries.**
122
+
123
+ The map output will be larger (more tokens per segment), but Gemini Flash is cheap enough that this is a good tradeoff. Only use a targeted prompt if the user explicitly asks for something narrow.
124
+
125
+ #### Sample General-Purpose Map Prompt
126
+
127
+ Use this as a starting point for the `system_prompt` parameter in `analyze_keyframes`:
128
+
129
+ ```
130
+ You are analyzing keyframes from a video. For each segment, describe everything you can observe:
131
+
132
+ - People visible: count, positions, identifying features (jersey numbers, clothing, names if visible)
133
+ - Actions and movements: what people are doing, direction of movement, interactions
134
+ - Objects of interest: ball location, equipment, vehicles, on-screen graphics
135
+ - Environment: setting, lighting, weather if outdoors
136
+ - Text on screen: scores, captions, titles, signs, timestamps
137
+ - Scene composition: camera angle, zoom level, any transitions between shots
138
+ - Any stoppages, pauses, or changes in activity
139
+
140
+ Be specific and factual. Describe what you see, not what you infer happened between frames.
141
+ ```
142
+
143
+ #### Sample Output Schema
144
+
145
+ ```json
146
+ {
147
+ "type": "object",
148
+ "properties": {
149
+ "scene_description": { "type": "string" },
150
+ "people": {
151
+ "type": "array",
152
+ "items": {
153
+ "type": "object",
154
+ "properties": {
155
+ "description": { "type": "string" },
156
+ "position": { "type": "string" },
157
+ "action": { "type": "string" }
158
+ }
159
+ }
160
+ },
161
+ "objects_of_interest": { "type": "array", "items": { "type": "string" } },
162
+ "on_screen_text": { "type": "array", "items": { "type": "string" } },
163
+ "camera": { "type": "string" },
164
+ "notable_events": { "type": "array", "items": { "type": "string" } }
165
+ }
166
+ }
167
+ ```
168
+
169
+ ### Clip Delivery
170
+
171
+ The `generate_clip` tool outputs clips as temporary files. These may not deliver reliably via sandbox attachments. For reliable delivery, use `host_bash` + ffmpeg to save clips to a user-specified location as a fallback.
172
+
173
+ ## Known Limitations — Vision Analysis
174
+
175
+ Gemini performs well at **spatial/descriptive analysis** from static keyframes:
176
+ - Player positions, formations, and spacing
177
+ - Jersey numbers and identifying features
178
+ - Ball location and which team has possession
179
+ - Score and on-screen text
180
+ - Camera angles and scene composition
181
+
182
+ Gemini **hallucinates when asked to detect fast temporal events** from static frames, regardless of frame density:
183
+ - Turnovers, steals, fouls, and specific plays
184
+ - Fast transitions and split-second actions
185
+ - Causality between frames (what "happened" vs. what's visible)
186
+
187
+ The model is good at describing **what is there** but bad at detecting **what happened**. Structure your map prompts and queries accordingly — ask the model to describe scenes, then use `query_media` (Claude) to reason about patterns and events across the descriptive data.
188
+
113
189
  ## Operator Runbook
114
190
 
115
191
  ### Monitoring Progress
@@ -137,16 +213,7 @@ After fixing the root cause, re-run the failed stage. The pipeline is resumable
137
213
 
138
214
  ### Cost Expectations
139
215
 
140
- The Map phase (Gemini 2.5 Flash) is the primary cost driver. Cost scales with video duration, keyframe interval, and segment size:
141
-
142
- | Video Duration | Interval | Keyframes | Segments (~10 frames each) | Estimated Map Cost |
143
- |----------------|----------|-----------|----------------------------|--------------------|
144
- | 30 min | 3s | ~600 | ~60 | ~$0.06 |
145
- | 60 min | 3s | ~1,200 | ~120 | ~$0.12 |
146
- | 90 min | 3s | ~1,800 | ~180 | ~$0.18 |
147
- | 90 min | 5s | ~1,080 | ~108 | ~$0.11 |
148
-
149
- The Reduce phase (Claude) adds a small additional cost per query. The `media_diagnostics` tool provides per-asset cost estimates.
216
+ Use `media_diagnostics` to get per-asset cost estimates. The Map phase (Gemini) is the primary cost driver it scales with video duration and keyframe interval. The Q&A phase (Claude) is negligible per query.
150
217
 
151
218
  ### Known Limitations
152
219
 
@@ -67,11 +67,11 @@
67
67
  },
68
68
  "interval_seconds": {
69
69
  "type": "number",
70
- "description": "Interval between keyframes in seconds. Default: 3"
70
+ "description": "Interval between keyframes in seconds. Default: 1. Use 0.5 for sports/action content."
71
71
  },
72
72
  "segment_duration": {
73
73
  "type": "number",
74
- "description": "Duration of each segment window in seconds. Default: 20"
74
+ "description": "Duration of each segment window in seconds. Default: 15"
75
75
  },
76
76
  "dead_time_threshold": {
77
77
  "type": "number",
@@ -83,7 +83,7 @@
83
83
  },
84
84
  "skip_dead_time": {
85
85
  "type": "boolean",
86
- "description": "Whether to detect and skip dead time. Default: true"
86
+ "description": "Whether to detect and skip dead time. Default: false. Can be too aggressive for continuous action video like sports."
87
87
  },
88
88
  "short_edge": {
89
89
  "type": "number",
@@ -355,13 +355,13 @@ export async function preprocessForAsset(
355
355
  onProgress?: (msg: string) => void,
356
356
  ): Promise<PreprocessManifest> {
357
357
  const config: PreprocessConfig = {
358
- intervalSeconds: options.intervalSeconds ?? 3,
359
- segmentDuration: options.segmentDuration ?? 20,
358
+ intervalSeconds: options.intervalSeconds ?? 1,
359
+ segmentDuration: options.segmentDuration ?? 15,
360
360
  deadTimeThreshold: options.deadTimeThreshold ?? 0.02,
361
361
  shortEdge: options.shortEdge ?? 480,
362
362
  };
363
363
 
364
- const skipDeadTime = options.skipDeadTime ?? true;
364
+ const skipDeadTime = options.skipDeadTime ?? false;
365
365
 
366
366
  const asset = getMediaAssetById(assetId);
367
367
  if (!asset) {
@@ -285,4 +285,9 @@ export const DEFAULT_CONFIG: AssistantConfig = {
285
285
  sigkillGracePeriodMs: 2000,
286
286
  titleGenerationMaxTokens: 30,
287
287
  },
288
+ notifications: {
289
+ enabled: false,
290
+ shadowMode: true,
291
+ decisionModel: 'claude-haiku-4-5-20251001',
292
+ },
288
293
  };
@@ -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';
@@ -19,6 +19,10 @@ const DAEMON_TIMEOUT_DEFAULTS = {
19
19
  sigkillGracePeriodMs: 2000,
20
20
  };
21
21
 
22
+ function isPositiveInteger(v: unknown): v is number {
23
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
24
+ }
25
+
22
26
  /**
23
27
  * Read daemon timeout values directly from the config JSON file, bypassing
24
28
  * loadConfig() and its ensureMigratedDataDir()/ensureDataDir() side effects.
@@ -30,18 +34,15 @@ function readDaemonTimeouts(): typeof DAEMON_TIMEOUT_DEFAULTS {
30
34
  const raw = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8'));
31
35
  if (raw.daemon && typeof raw.daemon === 'object') {
32
36
  return {
33
- startupSocketWaitMs:
34
- typeof raw.daemon.startupSocketWaitMs === 'number'
35
- ? raw.daemon.startupSocketWaitMs
36
- : DAEMON_TIMEOUT_DEFAULTS.startupSocketWaitMs,
37
- stopTimeoutMs:
38
- typeof raw.daemon.stopTimeoutMs === 'number'
39
- ? raw.daemon.stopTimeoutMs
40
- : DAEMON_TIMEOUT_DEFAULTS.stopTimeoutMs,
41
- sigkillGracePeriodMs:
42
- typeof raw.daemon.sigkillGracePeriodMs === 'number'
43
- ? raw.daemon.sigkillGracePeriodMs
44
- : DAEMON_TIMEOUT_DEFAULTS.sigkillGracePeriodMs,
37
+ startupSocketWaitMs: isPositiveInteger(raw.daemon.startupSocketWaitMs)
38
+ ? raw.daemon.startupSocketWaitMs
39
+ : DAEMON_TIMEOUT_DEFAULTS.startupSocketWaitMs,
40
+ stopTimeoutMs: isPositiveInteger(raw.daemon.stopTimeoutMs)
41
+ ? raw.daemon.stopTimeoutMs
42
+ : DAEMON_TIMEOUT_DEFAULTS.stopTimeoutMs,
43
+ sigkillGracePeriodMs: isPositiveInteger(raw.daemon.sigkillGracePeriodMs)
44
+ ? raw.daemon.sigkillGracePeriodMs
45
+ : DAEMON_TIMEOUT_DEFAULTS.sigkillGracePeriodMs,
45
46
  };
46
47
  }
47
48
  } catch {
@@ -109,10 +109,17 @@ export function handleSubagentMessage(
109
109
  return;
110
110
  }
111
111
 
112
- const sent = manager.sendMessage(msg.subagentId, msg.content);
112
+ const result = manager.sendMessage(msg.subagentId, msg.content);
113
113
 
114
- if (!sent) {
115
- log.warn({ subagentId: msg.subagentId }, 'Client sent message to terminal subagent');
114
+ if (result === 'queue_full') {
115
+ log.warn({ subagentId: msg.subagentId }, 'Subagent message rejected queue full');
116
+ ctx.send(socket, {
117
+ type: 'error',
118
+ message: `Subagent "${msg.subagentId}" message queue is full. Please wait for current messages to be processed.`,
119
+ category: 'queue_full',
120
+ });
121
+ } else if (result !== 'sent') {
122
+ log.warn({ subagentId: msg.subagentId, reason: result }, 'Client sent message to terminal subagent');
116
123
  ctx.send(socket, {
117
124
  type: 'error',
118
125
  message: `Subagent "${msg.subagentId}" not found or in terminal state.`,
@@ -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';
@@ -35,6 +36,8 @@ import { QdrantManager } from '../memory/qdrant-manager.js';
35
36
  import { initQdrantClient } from '../memory/qdrant-client.js';
36
37
  import { startScheduler } from '../schedule/scheduler.js';
37
38
  import { RuntimeHttpServer } from '../runtime/http-server.js';
39
+ import { assistantEventHub } from '../runtime/assistant-event-hub.js';
40
+ import * as attachmentsStore from '../memory/attachments-store.js';
38
41
  import { getHookManager } from '../hooks/manager.js';
39
42
  import { installTemplates } from '../hooks/templates.js';
40
43
  import { installCliLaunchers } from './install-cli-launchers.js';
@@ -42,10 +45,12 @@ import { HeartbeatService } from '../workspace/heartbeat-service.js';
42
45
  import { AgentHeartbeatService } from '../agent-heartbeat/agent-heartbeat-service.js';
43
46
  import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
44
47
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
48
+ import { emitNotificationSignal, registerBroadcastFn } from '../notifications/emit-signal.js';
45
49
  import { createApprovalCopyGenerator, createApprovalConversationGenerator } from './approval-generators.js';
46
50
  import { initializeProvidersAndTools, registerWatcherProviders, registerMessagingProviders } from './providers-setup.js';
47
51
  import { installShutdownHandlers } from './shutdown-handlers.js';
48
52
  import { writePid, cleanupPidFile } from './daemon-control.js';
53
+ import { initPairingHandlers } from './handlers/pairing.js';
49
54
 
50
55
  // Re-export public API so existing consumers don't need to change imports
51
56
  export {
@@ -184,38 +189,112 @@ export async function runDaemon(): Promise<void> {
184
189
  registerWatcherProviders();
185
190
  registerMessagingProviders();
186
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
+
187
196
  const scheduler = startScheduler(
188
197
  async (conversationId, message) => {
189
198
  await server.processMessage(conversationId, message);
190
199
  },
191
200
  (reminder) => {
201
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
192
202
  server.broadcast({
193
203
  type: 'reminder_fired',
194
204
  reminderId: reminder.id,
195
205
  label: reminder.label,
196
206
  message: reminder.message,
197
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
+ });
198
226
  },
199
227
  (schedule) => {
228
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
200
229
  server.broadcast({
201
230
  type: 'schedule_complete',
202
231
  scheduleId: schedule.id,
203
232
  name: schedule.name,
204
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
+ });
205
250
  },
206
251
  (notification) => {
252
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
207
253
  server.broadcast({
208
254
  type: 'watcher_notification',
209
255
  title: notification.title,
210
256
  body: notification.body,
211
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
+ });
212
274
  },
213
275
  (params) => {
276
+ // Legacy IPC broadcast for backward compatibility with desktop client UI
214
277
  server.broadcast({
215
278
  type: 'watcher_escalation',
216
279
  title: params.title,
217
280
  body: params.body,
218
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
+ });
219
298
  },
220
299
  );
221
300
 
@@ -247,6 +326,8 @@ export async function runDaemon(): Promise<void> {
247
326
 
248
327
  const hostname = getRuntimeHttpHost();
249
328
 
329
+ const runOrchestrator = server.createRunOrchestrator();
330
+
250
331
  runtimeHttp = new RuntimeHttpServer({
251
332
  port: httpPort,
252
333
  hostname,
@@ -255,15 +336,33 @@ export async function runDaemon(): Promise<void> {
255
336
  server.processMessage(conversationId, content, attachmentIds, options, sourceChannel),
256
337
  persistAndProcessMessage: (conversationId, content, attachmentIds, options, sourceChannel) =>
257
338
  server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel),
258
- runOrchestrator: server.createRunOrchestrator(),
339
+ runOrchestrator,
259
340
  interfacesDir: getInterfacesDir(),
260
341
  approvalCopyGenerator: createApprovalCopyGenerator(),
261
342
  approvalConversationGenerator: createApprovalConversationGenerator(),
343
+ sendMessageDeps: {
344
+ getOrCreateSession: (conversationId) =>
345
+ server.getSessionForMessages(conversationId),
346
+ assistantEventHub,
347
+ resolveAttachments: (attachmentIds) =>
348
+ attachmentsStore.getAttachmentsByIds(attachmentIds).map((a) => ({
349
+ id: a.id,
350
+ filename: a.originalFilename,
351
+ mimeType: a.mimeType,
352
+ data: a.dataBase64,
353
+ })),
354
+ },
262
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);
263
361
  try {
264
362
  await runtimeHttp.start();
265
363
  setRelayBroadcast((msg) => server.broadcast(msg));
266
364
  runtimeHttp.setPairingBroadcast((msg) => server.broadcast(msg));
365
+ initPairingHandlers(runtimeHttp.getPairingStore(), bearerToken);
267
366
  server.setHttpPort(httpPort);
268
367
  log.info({ port: httpPort, hostname }, 'Daemon startup: runtime HTTP server listening');
269
368
  } catch (err) {
@@ -819,6 +819,14 @@ export class DaemonServer {
819
819
  return { messageId };
820
820
  }
821
821
 
822
+ /**
823
+ * Expose session lookup for the POST /v1/messages handler.
824
+ * The handler manages busy-state checking and queueing itself.
825
+ */
826
+ async getSessionForMessages(conversationId: string): Promise<Session> {
827
+ return this.getOrCreateSession(conversationId, undefined, true);
828
+ }
829
+
822
830
  createRunOrchestrator(): RunOrchestrator {
823
831
  return new RunOrchestrator({
824
832
  getOrCreateSession: (conversationId, transport) =>
@@ -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
  }