@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ ArchiveResult,
9
10
  ConnectionInfo,
10
11
  Conversation,
11
12
  HistoryOptions,
@@ -13,6 +14,7 @@ import type {
13
14
  Message,
14
15
  SearchOptions,
15
16
  SearchResult,
17
+ SenderDigestResult,
16
18
  SendOptions,
17
19
  SendResult,
18
20
  } from './provider-types.js';
@@ -38,6 +40,11 @@ export interface MessagingProvider {
38
40
  getThreadReplies?(token: string, conversationId: string, threadId: string, options?: HistoryOptions): Promise<Message[]>;
39
41
  markRead?(token: string, conversationId: string, messageId?: string): Promise<void>;
40
42
 
43
+ /** Scan messages and group by sender for bulk cleanup (e.g. newsletter decluttering). */
44
+ senderDigest?(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult>;
45
+ /** Archive messages matching a search query. */
46
+ archiveByQuery?(token: string, query: string): Promise<ArchiveResult>;
47
+
41
48
  /**
42
49
  * Override the default credential check used by getConnectedProviders().
43
50
  * When present, the registry calls this instead of looking for
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { MessagingProvider } from '../../provider.js';
9
9
  import type {
10
+ ArchiveResult,
10
11
  ConnectionInfo,
11
12
  Conversation,
12
13
  HistoryOptions,
@@ -14,6 +15,8 @@ import type {
14
15
  Message,
15
16
  SearchOptions,
16
17
  SearchResult,
18
+ SenderDigestEntry,
19
+ SenderDigestResult,
17
20
  SendOptions,
18
21
  SendResult,
19
22
  } from '../../provider-types.js';
@@ -191,4 +194,128 @@ export const gmailMessagingProvider: MessagingProvider = {
191
194
  if (!messageId) return;
192
195
  await gmail.modifyMessage(token, messageId, { removeLabelIds: ['UNREAD'] });
193
196
  },
197
+
198
+ async senderDigest(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult> {
199
+ const maxMessages = Math.min(options?.maxMessages ?? 500, 2000);
200
+ const maxSenders = options?.maxSenders ?? 30;
201
+ const maxIdsPerSender = 1000;
202
+
203
+ const allMessageIds: string[] = [];
204
+ let pageToken: string | undefined;
205
+
206
+ while (allMessageIds.length < maxMessages) {
207
+ const pageSize = Math.min(100, maxMessages - allMessageIds.length);
208
+ const listResp = await gmail.listMessages(token, query, pageSize, pageToken);
209
+ const ids = (listResp.messages ?? []).map((m) => m.id);
210
+ if (ids.length === 0) break;
211
+ allMessageIds.push(...ids);
212
+ pageToken = listResp.nextPageToken ?? undefined;
213
+ if (!pageToken) break;
214
+ }
215
+
216
+ if (allMessageIds.length === 0) {
217
+ return { senders: [], totalScanned: 0, queryUsed: query };
218
+ }
219
+
220
+ const messages = await gmail.batchGetMessages(token, allMessageIds, 'metadata', [
221
+ 'From', 'List-Unsubscribe',
222
+ ]);
223
+
224
+ const senderMap = new Map<string, {
225
+ displayName: string; email: string; messageCount: number;
226
+ hasUnsubscribe: boolean; newestMessageId: string;
227
+ newestUnsubscribableMessageId: string | null; newestUnsubscribableEpoch: number;
228
+ messageIds: string[]; hasMore: boolean;
229
+ }>();
230
+
231
+ for (const msg of messages) {
232
+ const headers = msg.payload?.headers ?? [];
233
+ const fromHeader = headers.find((h) => h.name.toLowerCase() === 'from')?.value ?? '';
234
+ const listUnsub = headers.find((h) => h.name.toLowerCase() === 'list-unsubscribe')?.value;
235
+
236
+ const match = fromHeader.match(/^(.+?)\s*<([^>]+)>$/);
237
+ const email = match ? match[2].toLowerCase() : fromHeader.trim().toLowerCase();
238
+ const displayName = match ? match[1].replace(/^["']|["']$/g, '').trim() : '';
239
+ if (!email) continue;
240
+
241
+ let agg = senderMap.get(email);
242
+ if (!agg) {
243
+ agg = {
244
+ displayName, email, messageCount: 0, hasUnsubscribe: false,
245
+ newestMessageId: msg.id, newestUnsubscribableMessageId: null,
246
+ newestUnsubscribableEpoch: 0, messageIds: [], hasMore: false,
247
+ };
248
+ senderMap.set(email, agg);
249
+ }
250
+
251
+ agg.messageCount++;
252
+ if (listUnsub) agg.hasUnsubscribe = true;
253
+ if (!agg.displayName && displayName) agg.displayName = displayName;
254
+
255
+ if (agg.messageIds.length < maxIdsPerSender) {
256
+ agg.messageIds.push(msg.id);
257
+ } else {
258
+ agg.hasMore = true;
259
+ }
260
+
261
+ const msgEpoch = msg.internalDate ? Number(msg.internalDate) : 0;
262
+ if (listUnsub && msgEpoch >= agg.newestUnsubscribableEpoch) {
263
+ agg.newestUnsubscribableMessageId = msg.id;
264
+ agg.newestUnsubscribableEpoch = msgEpoch;
265
+ }
266
+ }
267
+
268
+ const sorted = [...senderMap.values()]
269
+ .sort((a, b) => b.messageCount - a.messageCount)
270
+ .slice(0, maxSenders);
271
+
272
+ const senders: SenderDigestEntry[] = sorted.map((s) => ({
273
+ id: Buffer.from(s.email).toString('base64url'),
274
+ displayName: s.displayName || s.email.split('@')[0],
275
+ email: s.email,
276
+ messageCount: s.messageCount,
277
+ hasUnsubscribe: s.hasUnsubscribe,
278
+ newestMessageId: (s.hasUnsubscribe && s.newestUnsubscribableMessageId)
279
+ ? s.newestUnsubscribableMessageId
280
+ : s.newestMessageId,
281
+ searchQuery: `from:${s.email} ${query}`,
282
+ messageIds: s.messageIds,
283
+ hasMore: s.hasMore,
284
+ }));
285
+
286
+ return { senders, totalScanned: allMessageIds.length, queryUsed: query };
287
+ },
288
+
289
+ async archiveByQuery(token: string, query: string): Promise<ArchiveResult> {
290
+ const maxMessages = 5000;
291
+ const batchModifyLimit = 1000;
292
+
293
+ const allMessageIds: string[] = [];
294
+ let pageToken: string | undefined;
295
+ let truncated = false;
296
+
297
+ while (allMessageIds.length < maxMessages) {
298
+ const listResp = await gmail.listMessages(token, query, Math.min(500, maxMessages - allMessageIds.length), pageToken);
299
+ const ids = (listResp.messages ?? []).map((m) => m.id);
300
+ if (ids.length === 0) break;
301
+ allMessageIds.push(...ids);
302
+ pageToken = listResp.nextPageToken ?? undefined;
303
+ if (!pageToken) break;
304
+ }
305
+
306
+ if (allMessageIds.length >= maxMessages && pageToken) {
307
+ truncated = true;
308
+ }
309
+
310
+ if (allMessageIds.length === 0) {
311
+ return { archived: 0 };
312
+ }
313
+
314
+ for (let i = 0; i < allMessageIds.length; i += batchModifyLimit) {
315
+ const chunk = allMessageIds.slice(i, i + batchModifyLimit);
316
+ await gmail.batchModifyMessages(token, chunk, { removeLabelIds: ['INBOX'] });
317
+ }
318
+
319
+ return { archived: allMessageIds.length, truncated };
320
+ },
194
321
  };
@@ -18,6 +18,7 @@ import { getGatewayInternalBaseUrl, getTwilioPhoneNumberEnv } from '../../../con
18
18
  import { loadConfig } from '../../../config/loader.js';
19
19
  import { getOrCreateConversation } from '../../../memory/conversation-key-store.js';
20
20
  import * as externalConversationStore from '../../../memory/external-conversation-store.js';
21
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../../runtime/assistant-scope.js';
21
22
  import { getSecureKey } from '../../../security/secure-keys.js';
22
23
  import { readHttpToken } from '../../../util/platform.js';
23
24
  import type { MessagingProvider } from '../../provider.js';
@@ -56,22 +57,8 @@ function hasTwilioCredentials(): boolean {
56
57
  );
57
58
  }
58
59
 
59
- /**
60
- * Resolve the configured SMS phone number.
61
- * Priority: assistant-scoped phone number > TWILIO_PHONE_NUMBER env > config sms.phoneNumber > secure key fallback.
62
- */
63
- function getPhoneNumber(assistantId?: string): string | undefined {
64
- // Check assistant-scoped phone number first
65
- if (assistantId) {
66
- try {
67
- const config = loadConfig();
68
- const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
69
- if (assistantPhone) return assistantPhone;
70
- } catch {
71
- // Config may not be available yet during early startup
72
- }
73
- }
74
-
60
+ /** Resolve the configured SMS phone number. */
61
+ function getPhoneNumber(): string | undefined {
75
62
  const fromEnv = getTwilioPhoneNumberEnv();
76
63
  if (fromEnv) return fromEnv;
77
64
 
@@ -85,15 +72,6 @@ function getPhoneNumber(assistantId?: string): string | undefined {
85
72
  return getSecureKey('credential:twilio:phone_number') || undefined;
86
73
  }
87
74
 
88
- function hasAnyAssistantPhoneNumber(): boolean {
89
- try {
90
- const config = loadConfig();
91
- return Object.keys(config.sms?.assistantPhoneNumbers ?? {}).length > 0;
92
- } catch {
93
- return false;
94
- }
95
- }
96
-
97
75
  export const smsMessagingProvider: MessagingProvider = {
98
76
  id: 'sms',
99
77
  displayName: 'SMS',
@@ -106,7 +84,16 @@ export const smsMessagingProvider: MessagingProvider = {
106
84
  * the `from` for outbound messages.
107
85
  */
108
86
  isConnected(): boolean {
109
- return hasTwilioCredentials() && (!!getPhoneNumber() || hasAnyAssistantPhoneNumber());
87
+ if (!hasTwilioCredentials()) return false;
88
+ if (getPhoneNumber()) return true;
89
+ try {
90
+ const config = loadConfig();
91
+ const mappings = config.sms?.assistantPhoneNumbers as Record<string, string> | undefined;
92
+ if (mappings && Object.keys(mappings).length > 0) return true;
93
+ } catch {
94
+ // Config may not be available yet
95
+ }
96
+ return false;
110
97
  },
111
98
 
112
99
  async testConnection(_token: string): Promise<ConnectionInfo> {
@@ -120,7 +107,26 @@ export const smsMessagingProvider: MessagingProvider = {
120
107
  }
121
108
 
122
109
  const phoneNumber = getPhoneNumber();
123
- if (!phoneNumber && !hasAnyAssistantPhoneNumber()) {
110
+ if (!phoneNumber) {
111
+ // Mirror isConnected(): fall back to assistant-scoped phone numbers
112
+ try {
113
+ const config = loadConfig();
114
+ const mappings = config.sms?.assistantPhoneNumbers as Record<string, string> | undefined;
115
+ if (mappings && Object.keys(mappings).length > 0) {
116
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
117
+ return {
118
+ connected: true,
119
+ user: 'assistant-scoped',
120
+ platform: 'sms',
121
+ metadata: {
122
+ accountSid: accountSid.slice(0, 6) + '...',
123
+ assistantPhoneNumbers: Object.keys(mappings).length,
124
+ },
125
+ };
126
+ }
127
+ } catch {
128
+ // Config may not be available yet
129
+ }
124
130
  return {
125
131
  connected: false,
126
132
  user: 'unknown',
@@ -133,12 +139,11 @@ export const smsMessagingProvider: MessagingProvider = {
133
139
 
134
140
  return {
135
141
  connected: true,
136
- user: phoneNumber ?? 'assistant-scoped numbers configured',
142
+ user: phoneNumber,
137
143
  platform: 'sms',
138
144
  metadata: {
139
145
  accountSid: accountSid.slice(0, 6) + '...',
140
- ...(phoneNumber ? { phoneNumber } : {}),
141
- hasAssistantScopedPhoneNumbers: hasAnyAssistantPhoneNumber(),
146
+ phoneNumber,
142
147
  },
143
148
  };
144
149
  },
@@ -152,16 +157,14 @@ export const smsMessagingProvider: MessagingProvider = {
152
157
 
153
158
  // Upsert external conversation binding so the conversation key mapping
154
159
  // exists for the next inbound SMS from this number.
160
+ const isSelfScope = !assistantId || assistantId === DAEMON_INTERNAL_ASSISTANT_ID;
155
161
  try {
156
162
  const sourceChannel = 'sms';
157
- const conversationKey = assistantId && assistantId !== 'self'
158
- ? `asst:${assistantId}:${sourceChannel}:${conversationId}`
159
- : `${sourceChannel}:${conversationId}`;
163
+ const conversationKey = isSelfScope
164
+ ? `${sourceChannel}:${conversationId}`
165
+ : `asst:${assistantId}:${sourceChannel}:${conversationId}`;
160
166
  const { conversationId: internalId } = getOrCreateConversation(conversationKey);
161
- // external_conversation_bindings is assistant-agnostic (unique by
162
- // sourceChannel + externalChatId). Restrict proactive writes to self so
163
- // multi-assistant sends cannot clobber each other's binding metadata.
164
- if (!assistantId || assistantId === 'self') {
167
+ if (isSelfScope) {
165
168
  externalConversationStore.upsertOutboundBinding({
166
169
  conversationId: internalId,
167
170
  sourceChannel,
@@ -5,6 +5,12 @@
5
5
  * The adapter broadcasts a `notification_intent` message that the Vellum
6
6
  * client can use to display a native notification (e.g. NSUserNotification
7
7
  * or UNUserNotificationCenter).
8
+ *
9
+ * Guardian-sensitive notifications (approval requests, escalation alerts)
10
+ * are annotated with `targetGuardianPrincipalId` so that only clients
11
+ * bound to the guardian identity display them. Non-guardian clients
12
+ * should ignore notifications with a `targetGuardianPrincipalId` that
13
+ * does not match their own identity.
8
14
  */
9
15
 
10
16
  import type { ServerMessage } from '../../daemon/ipc-contract.js';
@@ -21,6 +27,24 @@ const log = getLogger('notif-adapter-vellum');
21
27
 
22
28
  export type BroadcastFn = (msg: ServerMessage) => void;
23
29
 
30
+ /**
31
+ * Event name prefixes that carry guardian-sensitive content (approval
32
+ * requests, escalation alerts, access requests). Notifications for
33
+ * these events are scoped to bound guardian devices via
34
+ * `targetGuardianPrincipalId`.
35
+ */
36
+ const GUARDIAN_SENSITIVE_EVENT_PREFIXES = [
37
+ 'guardian.question',
38
+ 'ingress.escalation',
39
+ 'ingress.access_request',
40
+ ] as const;
41
+
42
+ export function isGuardianSensitiveEvent(sourceEventName: string): boolean {
43
+ return GUARDIAN_SENSITIVE_EVENT_PREFIXES.some(
44
+ (prefix) => sourceEventName === prefix || sourceEventName.startsWith(prefix + '.'),
45
+ );
46
+ }
47
+
24
48
  export class VellumAdapter implements ChannelAdapter {
25
49
  readonly channel: NotificationChannel = 'vellum';
26
50
 
@@ -30,8 +54,22 @@ export class VellumAdapter implements ChannelAdapter {
30
54
  this.broadcast = broadcast;
31
55
  }
32
56
 
33
- async send(payload: ChannelDeliveryPayload, _destination: ChannelDestination): Promise<DeliveryResult> {
57
+ async send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult> {
34
58
  try {
59
+ // For guardian-sensitive events, annotate the outbound message with
60
+ // the target guardian identity so clients can filter. The
61
+ // guardianPrincipalId comes from the vellum binding resolved by
62
+ // the destination resolver.
63
+ const guardianPrincipalId =
64
+ typeof destination.metadata?.guardianPrincipalId === 'string'
65
+ ? destination.metadata.guardianPrincipalId
66
+ : undefined;
67
+
68
+ const targetGuardianPrincipalId =
69
+ guardianPrincipalId && isGuardianSensitiveEvent(payload.sourceEventName)
70
+ ? guardianPrincipalId
71
+ : undefined;
72
+
35
73
  this.broadcast({
36
74
  type: 'notification_intent',
37
75
  deliveryId: payload.deliveryId,
@@ -39,10 +77,15 @@ export class VellumAdapter implements ChannelAdapter {
39
77
  title: payload.copy.title,
40
78
  body: payload.copy.body,
41
79
  deepLinkMetadata: payload.deepLinkTarget,
80
+ targetGuardianPrincipalId,
42
81
  } as ServerMessage);
43
82
 
44
83
  log.info(
45
- { sourceEventName: payload.sourceEventName, title: payload.copy.title },
84
+ {
85
+ sourceEventName: payload.sourceEventName,
86
+ title: payload.copy.title,
87
+ guardianScoped: targetGuardianPrincipalId != null,
88
+ },
46
89
  'Vellum notification intent broadcast',
47
90
  );
48
91
 
@@ -12,6 +12,7 @@
12
12
  import { v4 as uuid } from 'uuid';
13
13
 
14
14
  import { getLogger } from '../util/logger.js';
15
+ import { isGuardianSensitiveEvent } from './adapters/macos.js';
15
16
  import { pairDeliveryWithConversation } from './conversation-pairing.js';
16
17
  import { composeFallbackCopy } from './copy-composer.js';
17
18
  import { createDelivery, findDeliveryByDecisionAndChannel, updateDeliveryStatus } from './deliveries-store.js';
@@ -34,6 +35,8 @@ export interface ThreadCreatedInfo {
34
35
  conversationId: string;
35
36
  title: string;
36
37
  sourceEventName: string;
38
+ /** Present when the thread is for a guardian-sensitive notification. */
39
+ targetGuardianPrincipalId?: string;
37
40
  }
38
41
  export type OnThreadCreatedFn = (info: ThreadCreatedInfo) => void;
39
42
  export interface BroadcastDecisionOptions {
@@ -163,6 +166,18 @@ export class NotificationBroadcaster {
163
166
  if (channel === 'vellum' && pairing.conversationId) {
164
167
  deepLinkTarget = { ...deepLinkTarget, conversationId: pairing.conversationId };
165
168
 
169
+ // Resolve guardian scoping for thread-created events so clients
170
+ // can filter guardian-sensitive threads the same way they filter
171
+ // guardian-sensitive notification intents.
172
+ const guardianPrincipalId =
173
+ typeof destination.metadata?.guardianPrincipalId === 'string'
174
+ ? destination.metadata.guardianPrincipalId
175
+ : undefined;
176
+ const targetGuardianPrincipalId =
177
+ guardianPrincipalId && isGuardianSensitiveEvent(signal.sourceEventName)
178
+ ? guardianPrincipalId
179
+ : undefined;
180
+
166
181
  const threadTitle =
167
182
  copy.threadTitle ??
168
183
  copy.title ??
@@ -171,6 +186,7 @@ export class NotificationBroadcaster {
171
186
  conversationId: pairing.conversationId,
172
187
  title: threadTitle,
173
188
  sourceEventName: signal.sourceEventName,
189
+ targetGuardianPrincipalId,
174
190
  };
175
191
 
176
192
  // The per-dispatch onThreadCreated callback fires whenever a vellum
@@ -9,6 +9,10 @@
9
9
  * values from the context payload.
10
10
  */
11
11
 
12
+ import {
13
+ buildGuardianRequestCodeInstruction,
14
+ resolveGuardianQuestionInstructionMode,
15
+ } from './guardian-question-mode.js';
12
16
  import type { NotificationSignal } from './signal.js';
13
17
  import type { NotificationChannel, RenderedChannelCopy } from './types.js';
14
18
 
@@ -48,16 +52,34 @@ const TEMPLATES: Record<string, CopyTemplate> = {
48
52
  }
49
53
 
50
54
  const normalizedCode = requestCode.toUpperCase();
55
+ const modeResolution = resolveGuardianQuestionInstructionMode(payload);
56
+ const instruction = buildGuardianRequestCodeInstruction(normalizedCode, modeResolution.mode);
51
57
  return {
52
58
  title: 'Guardian Question',
53
- body: `${question}\n\nReference code: ${normalizedCode}. Reply "${normalizedCode} approve" or "${normalizedCode} reject".`,
59
+ body: `${question}\n\n${instruction}`,
54
60
  };
55
61
  },
56
62
 
57
63
  'ingress.access_request': (payload) => {
58
64
  const requester = str(payload.senderIdentifier, 'Someone');
59
65
  const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
60
- const lines: string[] = [`${requester} is requesting access to the assistant.`];
66
+ const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
67
+ const callerName = nonEmpty(typeof payload.senderName === 'string' ? payload.senderName : undefined);
68
+ const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
69
+ ? payload.previousMemberStatus
70
+ : undefined;
71
+ const lines: string[] = [];
72
+
73
+ // Voice-originated access requests include caller name context
74
+ if (sourceChannel === 'voice' && callerName) {
75
+ lines.push(`${callerName} (${str(payload.senderExternalUserId, requester)}) is calling and requesting access to the assistant.`);
76
+ } else {
77
+ lines.push(`${requester} is requesting access to the assistant.`);
78
+ }
79
+ if (previousMemberStatus === 'revoked') {
80
+ lines.push('Note: this user was previously revoked.');
81
+ }
82
+
61
83
  if (requestCode) {
62
84
  const code = requestCode.toUpperCase();
63
85
  lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
@@ -69,6 +91,32 @@ const TEMPLATES: Record<string, CopyTemplate> = {
69
91
  };
70
92
  },
71
93
 
94
+ 'ingress.access_request.callback_handoff': (payload) => {
95
+ const callerName = nonEmpty(typeof payload.callerName === 'string' ? payload.callerName : undefined);
96
+ const callerPhone = nonEmpty(typeof payload.callerPhoneNumber === 'string' ? payload.callerPhoneNumber : undefined);
97
+ const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
98
+ const memberId = nonEmpty(typeof payload.requesterMemberId === 'string' ? payload.requesterMemberId : undefined);
99
+
100
+ const callerIdentity = callerName && callerPhone
101
+ ? `${callerName} (${callerPhone})`
102
+ : callerName ?? callerPhone ?? 'An unknown caller';
103
+
104
+ const lines: string[] = [];
105
+ lines.push(`${callerIdentity} called and requested a callback while you were unreachable.`);
106
+
107
+ if (requestCode) {
108
+ lines.push(`Request code: ${requestCode.toUpperCase()}`);
109
+ }
110
+ if (memberId) {
111
+ lines.push(`This caller is a trusted contact (member ID: ${memberId}).`);
112
+ }
113
+
114
+ return {
115
+ title: 'Callback Requested',
116
+ body: lines.join('\n'),
117
+ };
118
+ },
119
+
72
120
  'ingress.escalation': (payload) => ({
73
121
  title: 'Escalation',
74
122
  body: str(payload.senderIdentifier, 'An incoming message') + ' needs attention',
@@ -18,6 +18,12 @@ import type { ModelIntent } from '../providers/types.js';
18
18
  import { getLogger } from '../util/logger.js';
19
19
  import { composeFallbackCopy } from './copy-composer.js';
20
20
  import { createDecision } from './decisions-store.js';
21
+ import {
22
+ buildGuardianRequestCodeInstruction,
23
+ hasGuardianRequestCodeInstruction,
24
+ resolveGuardianQuestionInstructionMode,
25
+ stripConflictingGuardianRequestInstructions,
26
+ } from './guardian-question-mode.js';
21
27
  import { getPreferenceSummary } from './preference-summary.js';
22
28
  import type { NotificationSignal, RoutingIntent } from './signal.js';
23
29
  import { buildThreadCandidates, serializeCandidatesForPrompt,type ThreadCandidateSet } from './thread-candidates.js';
@@ -409,18 +415,15 @@ export function validateThreadActions(
409
415
  function ensureGuardianRequestCodeInCopy(
410
416
  copy: RenderedChannelCopy,
411
417
  requestCode: string,
418
+ mode: 'approval' | 'answer',
412
419
  ): RenderedChannelCopy {
413
- const instruction = `Reference code: ${requestCode}. Reply "${requestCode} approve" or "${requestCode} reject".`;
414
- const hasParserCompatibleInstructions = (text: string | undefined): boolean => {
415
- if (typeof text !== 'string') return false;
416
- const upper = text.toUpperCase();
417
- return upper.includes(`${requestCode} APPROVE`) && upper.includes(`${requestCode} REJECT`);
418
- };
420
+ const instruction = buildGuardianRequestCodeInstruction(requestCode, mode);
419
421
 
420
422
  const ensureText = (text: string | undefined): string => {
421
423
  const base = typeof text === 'string' ? text.trim() : '';
422
- if (hasParserCompatibleInstructions(base)) return base;
423
- return base.length > 0 ? `${base}\n\n${instruction}` : instruction;
424
+ const sanitized = stripConflictingGuardianRequestInstructions(base, requestCode, mode);
425
+ if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode)) return sanitized;
426
+ return sanitized.length > 0 ? `${sanitized}\n\n${instruction}` : instruction;
424
427
  };
425
428
 
426
429
  return {
@@ -445,6 +448,16 @@ function enforceGuardianRequestCode(
445
448
  if (typeof rawCode !== 'string' || rawCode.trim().length === 0) return decision;
446
449
 
447
450
  const requestCode = rawCode.trim().toUpperCase();
451
+ const modeResolution = resolveGuardianQuestionInstructionMode(signal.contextPayload);
452
+ if (modeResolution.legacyFallbackUsed) {
453
+ log.warn(
454
+ {
455
+ signalId: signal.signalId,
456
+ requestKind: modeResolution.requestKind,
457
+ },
458
+ 'guardian.question payload missing/invalid typed fields; using legacy instruction-mode fallback',
459
+ );
460
+ }
448
461
  const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
449
462
  ...decision.renderedCopy,
450
463
  };
@@ -452,7 +465,7 @@ function enforceGuardianRequestCode(
452
465
  for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
453
466
  const copy = nextCopy[channel];
454
467
  if (!copy) continue;
455
- nextCopy[channel] = ensureGuardianRequestCodeInCopy(copy, requestCode);
468
+ nextCopy[channel] = ensureGuardianRequestCodeInCopy(copy, requestCode, modeResolution.mode);
456
469
  }
457
470
 
458
471
  return {
@@ -2,7 +2,10 @@
2
2
  * Resolves per-channel destination endpoints for notification delivery.
3
3
  *
4
4
  * - Vellum: no external endpoint needed — delivery goes through the IPC
5
- * broadcast mechanism to connected desktop/mobile clients.
5
+ * broadcast mechanism to connected desktop/mobile clients. The
6
+ * guardianPrincipalId from the vellum binding is included in metadata
7
+ * so downstream adapters can scope guardian-sensitive notifications to
8
+ * bound guardian devices only.
6
9
  * - Binding-based channels (telegram, sms): require a chat/delivery ID
7
10
  * sourced from the guardian binding for the assistant.
8
11
  */
@@ -35,7 +38,18 @@ export function resolveDestinations(
35
38
  switch (channel as NotificationChannel) {
36
39
  case 'vellum': {
37
40
  // Vellum delivery is local IPC — no external endpoint required.
38
- result.set('vellum', { channel: 'vellum' });
41
+ // Include the guardianPrincipalId from the vellum binding so the
42
+ // adapter can annotate guardian-sensitive notifications for scoped
43
+ // delivery to bound guardian devices.
44
+ const vellumBinding = getActiveBinding(assistantId, 'vellum');
45
+ const metadata: Record<string, unknown> = {};
46
+ if (vellumBinding) {
47
+ metadata.guardianPrincipalId = vellumBinding.guardianExternalUserId;
48
+ }
49
+ result.set('vellum', {
50
+ channel: 'vellum',
51
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
52
+ });
39
53
  break;
40
54
  }
41
55
  case 'telegram':
@@ -13,6 +13,7 @@ import { v4 as uuid } from 'uuid';
13
13
 
14
14
  import { getDeliverableChannels } from '../channels/config.js';
15
15
  import { getActiveBinding } from '../memory/channel-guardian-store.js';
16
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
16
17
  import { getLogger } from '../util/logger.js';
17
18
  import { type BroadcastFn, VellumAdapter } from './adapters/macos.js';
18
19
  import { SmsAdapter } from './adapters/sms.js';
@@ -23,7 +24,12 @@ import { updateDecision } from './decisions-store.js';
23
24
  import { type DeterministicCheckContext, runDeterministicChecks } from './deterministic-checks.js';
24
25
  import { createEvent, updateEventDedupeKey } from './events-store.js';
25
26
  import { dispatchDecision } from './runtime-dispatch.js';
26
- import type { AttentionHints, NotificationSignal, RoutingIntent } from './signal.js';
27
+ import type {
28
+ AttentionHints,
29
+ NotificationContextPayload,
30
+ NotificationSignal,
31
+ RoutingIntent,
32
+ } from './signal.js';
27
33
  import type { NotificationChannel, NotificationDeliveryResult } from './types.js';
28
34
 
29
35
  const log = getLogger('emit-signal');
@@ -66,9 +72,10 @@ function getBroadcaster(): NotificationBroadcaster {
66
72
  conversationId: info.conversationId,
67
73
  title: info.title,
68
74
  sourceEventName: info.sourceEventName,
75
+ targetGuardianPrincipalId: info.targetGuardianPrincipalId,
69
76
  });
70
77
  log.info(
71
- { conversationId: info.conversationId },
78
+ { conversationId: info.conversationId, guardianScoped: info.targetGuardianPrincipalId != null },
72
79
  'Emitted notification_thread_created push event',
73
80
  );
74
81
  });
@@ -116,9 +123,9 @@ function getConnectedChannels(assistantId: string): NotificationChannel[] {
116
123
 
117
124
  // ── Public API ─────────────────────────────────────────────────────────
118
125
 
119
- export interface EmitSignalParams {
126
+ export interface EmitSignalParams<TEventName extends string = string> {
120
127
  /** Free-form event name, e.g. 'reminder.fired', 'schedule.complete'. */
121
- sourceEventName: string;
128
+ sourceEventName: TEventName;
122
129
  /** Source channel that produced the event. */
123
130
  sourceChannel: string;
124
131
  /** Session or conversation ID from the source context. */
@@ -128,7 +135,7 @@ export interface EmitSignalParams {
128
135
  /** Attention hints for the decision engine. */
129
136
  attentionHints: AttentionHints;
130
137
  /** Arbitrary context payload passed to the decision engine. */
131
- contextPayload?: Record<string, unknown>;
138
+ contextPayload?: NotificationContextPayload<TEventName>;
132
139
  /** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
133
140
  routingIntent?: RoutingIntent;
134
141
  /** Free-form hints from the source for the decision engine. */
@@ -168,18 +175,20 @@ export interface EmitSignalResult {
168
175
  * Fire-and-forget safe by default: errors are caught and logged unless
169
176
  * `throwOnError` is enabled by the caller.
170
177
  */
171
- export async function emitNotificationSignal(params: EmitSignalParams): Promise<EmitSignalResult> {
178
+ export async function emitNotificationSignal<TEventName extends string>(
179
+ params: EmitSignalParams<TEventName>,
180
+ ): Promise<EmitSignalResult> {
172
181
  const signalId = uuid();
173
- const assistantId = params.assistantId ?? 'self';
182
+ const assistantId = params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
174
183
 
175
- const signal: NotificationSignal = {
184
+ const signal: NotificationSignal<TEventName> = {
176
185
  signalId,
177
186
  assistantId,
178
187
  createdAt: Date.now(),
179
188
  sourceChannel: params.sourceChannel,
180
189
  sourceSessionId: params.sourceSessionId,
181
190
  sourceEventName: params.sourceEventName,
182
- contextPayload: params.contextPayload ?? {},
191
+ contextPayload: (params.contextPayload ?? {}) as NotificationContextPayload<TEventName>,
183
192
  attentionHints: params.attentionHints,
184
193
  routingIntent: params.routingIntent,
185
194
  routingHints: params.routingHints,