@vellumai/assistant 0.4.3 → 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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -488,6 +488,24 @@
488
488
  </div>
489
489
  </section>
490
490
 
491
+ <!-- Brain Knowledge -->
492
+ <section class="section" id="home-base-brain-lane">
493
+ <div class="section-header anim anim-d6"><h2>Knowledge</h2></div>
494
+
495
+ <div class="card-stack">
496
+ <div class="card feature anim anim-d6">
497
+ <div class="card-icon">🧠</div>
498
+ <div class="card-body">
499
+ <div class="task">Knowledge Brain</div>
500
+ <div class="task-meta" id="home-base-brain-count">Loading...</div>
501
+ <div class="task-controls">
502
+ <a href="/v1/brain-graph-ui" class="task-button primary" id="home-base-brain-view">View →</a>
503
+ </div>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ </section>
508
+
491
509
  <!-- Onboarding -->
492
510
  <section class="section" id="home-base-onboarding-lane">
493
511
  <div class="section-header anim anim-d6"><h2>Set up your assistant</h2></div>
@@ -656,6 +674,28 @@
656
674
  });
657
675
  });
658
676
  }
677
+
678
+ // Fetch brain graph count for the Knowledge Brain card.
679
+ (function () {
680
+ var countEl = byId('home-base-brain-count');
681
+ if (!countEl) return;
682
+ var apiToken = document.querySelector('meta[name="api-token"]');
683
+ var headers = {};
684
+ if (apiToken && apiToken.content) headers['Authorization'] = 'Bearer ' + apiToken.content;
685
+ fetch('/v1/brain-graph', { headers: headers })
686
+ .then(function (res) {
687
+ if (!res.ok) throw new Error('HTTP ' + res.status);
688
+ return res.json();
689
+ })
690
+ .then(function (data) {
691
+ var entities = (data.entities || []).length;
692
+ var relations = (data.relations || []).length;
693
+ countEl.textContent = entities + ' nodes \u2022 ' + relations + ' relations';
694
+ })
695
+ .catch(function () {
696
+ countEl.textContent = '\u2014';
697
+ });
698
+ })();
659
699
  })();
660
700
  </script>
661
701
  </body>
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Platform callback route registration for containerized deployments.
3
+ *
4
+ * When the assistant daemon runs inside a container (IS_CONTAINERIZED=true)
5
+ * with a configured PLATFORM_BASE_URL and PLATFORM_ASSISTANT_ID, external
6
+ * service callbacks (Twilio webhooks, OAuth redirects, Telegram webhooks, etc.)
7
+ * must route through the platform's gateway proxy instead of hitting the
8
+ * assistant directly.
9
+ *
10
+ * This module registers callback routes with the platform's internal
11
+ * gateway endpoint so the platform knows how to forward inbound provider
12
+ * webhooks to the correct containerized assistant instance.
13
+ *
14
+ * The platform endpoint is:
15
+ * POST {PLATFORM_BASE_URL}/v1/internal/gateway/callback-routes/register/
16
+ *
17
+ * It accepts { assistant_id, callback_path, type } and returns a stable
18
+ * callback_url that external services should use.
19
+ */
20
+
21
+ import { getPlatformAssistantId, getPlatformBaseUrl, getPlatformInternalApiKey } from '../config/env.js';
22
+ import { getIsContainerized } from '../config/env-registry.js';
23
+ import { getLogger } from '../util/logger.js';
24
+
25
+ const log = getLogger('platform-callback-registration');
26
+
27
+ /**
28
+ * Whether the daemon should register callback routes with the platform.
29
+ * True when IS_CONTAINERIZED, PLATFORM_BASE_URL, and PLATFORM_ASSISTANT_ID
30
+ * are all set.
31
+ */
32
+ export function shouldUsePlatformCallbacks(): boolean {
33
+ return getIsContainerized() && !!getPlatformBaseUrl() && !!getPlatformAssistantId();
34
+ }
35
+
36
+ interface RegisterCallbackRouteResponse {
37
+ callback_url: string;
38
+ callback_path: string;
39
+ type: string;
40
+ assistant_id: string;
41
+ }
42
+
43
+ /**
44
+ * Register a callback route with the platform's internal gateway endpoint.
45
+ *
46
+ * @param callbackPath - The path portion after the ingress base URL
47
+ * (e.g. "webhooks/twilio/voice"). Leading/trailing slashes are stripped
48
+ * by the platform.
49
+ * @param type - The route type identifier (e.g. "twilio_voice", "twilio_sms",
50
+ * "twilio_status", "oauth", "telegram").
51
+ * @returns The platform-provided callback URL that external services should use.
52
+ * @throws If the platform request fails.
53
+ */
54
+ export async function registerCallbackRoute(
55
+ callbackPath: string,
56
+ type: string,
57
+ ): Promise<string> {
58
+ const platformBaseUrl = getPlatformBaseUrl().replace(/\/+$/, '');
59
+ const assistantId = getPlatformAssistantId();
60
+ const apiKey = getPlatformInternalApiKey();
61
+
62
+ const url = `${platformBaseUrl}/v1/internal/gateway/callback-routes/register/`;
63
+
64
+ const headers: Record<string, string> = {
65
+ 'Content-Type': 'application/json',
66
+ };
67
+
68
+ if (apiKey) {
69
+ headers['Authorization'] = `Bearer ${apiKey}`;
70
+ }
71
+
72
+ const body = JSON.stringify({
73
+ assistant_id: assistantId,
74
+ callback_path: callbackPath,
75
+ type,
76
+ });
77
+
78
+ log.debug({ callbackPath, type }, 'Registering platform callback route');
79
+
80
+ const response = await fetch(url, {
81
+ method: 'POST',
82
+ headers,
83
+ body,
84
+ signal: AbortSignal.timeout(10_000),
85
+ });
86
+
87
+ if (!response.ok) {
88
+ const detail = await response.text().catch(() => '');
89
+ throw new Error(
90
+ `Platform callback route registration failed (HTTP ${response.status}): ${detail}`,
91
+ );
92
+ }
93
+
94
+ const data = (await response.json()) as RegisterCallbackRouteResponse;
95
+
96
+ log.info(
97
+ { callbackPath, type, callbackUrl: data.callback_url },
98
+ 'Platform callback route registered',
99
+ );
100
+
101
+ return data.callback_url;
102
+ }
103
+
104
+ /**
105
+ * Resolve a callback URL, registering with the platform when containerized.
106
+ *
107
+ * When platform callbacks are enabled, registers the route and returns the
108
+ * platform's stable callback URL (optionally with query parameters appended).
109
+ * Otherwise evaluates the lazy direct URL supplier and returns that value.
110
+ *
111
+ * The `directUrl` parameter is a **lazy supplier** (a function returning a
112
+ * string) rather than an eagerly-evaluated string. This is critical because
113
+ * the direct URL builders (e.g. `getTwilioVoiceWebhookUrl`) call
114
+ * `getPublicBaseUrl()` which throws when no public ingress URL is configured.
115
+ * In containerized environments that rely solely on platform callbacks, the
116
+ * direct URL is never needed — deferring evaluation avoids the throw.
117
+ *
118
+ * @param directUrl - Lazy supplier for the direct callback URL.
119
+ * @param callbackPath - The path to register (e.g. "webhooks/twilio/voice").
120
+ * @param type - The route type identifier.
121
+ * @param queryParams - Optional query parameters to append to the resolved URL.
122
+ * @returns The resolved callback URL.
123
+ */
124
+ export async function resolveCallbackUrl(
125
+ directUrl: () => string,
126
+ callbackPath: string,
127
+ type: string,
128
+ queryParams?: Record<string, string>,
129
+ ): Promise<string> {
130
+ if (!shouldUsePlatformCallbacks()) {
131
+ return directUrl();
132
+ }
133
+
134
+ try {
135
+ let url = await registerCallbackRoute(callbackPath, type);
136
+ if (queryParams && Object.keys(queryParams).length > 0) {
137
+ const params = new URLSearchParams(queryParams);
138
+ const separator = url.includes('?') ? '&' : '?';
139
+ url = `${url}${separator}${params.toString()}`;
140
+ }
141
+ return url;
142
+ } catch (err) {
143
+ log.warn(
144
+ { err, callbackPath, type },
145
+ 'Failed to register platform callback route, falling back to direct URL',
146
+ );
147
+ try {
148
+ return directUrl();
149
+ } catch (fallbackErr) {
150
+ log.error(
151
+ { fallbackErr, callbackPath, type },
152
+ 'Direct URL fallback also failed after platform registration failure',
153
+ );
154
+ throw err;
155
+ }
156
+ }
157
+ }
@@ -292,7 +292,7 @@ export interface UpdateCanonicalGuardianRequestParams {
292
292
  status?: CanonicalRequestStatus;
293
293
  answerText?: string;
294
294
  decidedByExternalUserId?: string;
295
- followupState?: string;
295
+ followupState?: string | null;
296
296
  expiresAt?: string;
297
297
  }
298
298
 
@@ -1,6 +1,7 @@
1
1
  import { getDb } from './db-connection.js';
2
2
  import {
3
3
  addCoreColumns,
4
+ createActorTokenRecordsTable,
4
5
  createAssistantInboxTables,
5
6
  createCallSessionsTables,
6
7
  createCanonicalGuardianTables,
@@ -169,5 +170,8 @@ export function initializeDb(): void {
169
170
  // 27. Voice invite display metadata (friend_name, guardian_name) for personalized prompts
170
171
  migrateVoiceInviteDisplayMetadata(database);
171
172
 
173
+ // 28. Actor token records (hash-only actor token persistence)
174
+ createActorTokenRecordsTable(database);
175
+
172
176
  validateMigrationState(database);
173
177
  }
@@ -0,0 +1,39 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Create the actor_token_records table for hash-only actor token persistence.
5
+ *
6
+ * Stores the SHA-256 hash of each actor token alongside metadata for
7
+ * verification and revocation. The raw token plaintext is never stored.
8
+ */
9
+ export function createActorTokenRecordsTable(database: DrizzleDb): void {
10
+ database.run(/*sql*/ `
11
+ CREATE TABLE IF NOT EXISTS actor_token_records (
12
+ id TEXT PRIMARY KEY,
13
+ token_hash TEXT NOT NULL,
14
+ assistant_id TEXT NOT NULL,
15
+ guardian_principal_id TEXT NOT NULL,
16
+ hashed_device_id TEXT NOT NULL,
17
+ platform TEXT NOT NULL,
18
+ status TEXT NOT NULL DEFAULT 'active',
19
+ issued_at INTEGER NOT NULL,
20
+ expires_at INTEGER,
21
+ created_at INTEGER NOT NULL,
22
+ updated_at INTEGER NOT NULL
23
+ )
24
+ `);
25
+
26
+ // Unique active token per device binding
27
+ database.run(
28
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
29
+ ON actor_token_records(assistant_id, guardian_principal_id, hashed_device_id)
30
+ WHERE status = 'active'`,
31
+ );
32
+
33
+ // Token hash lookup for verification
34
+ database.run(
35
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_actor_tokens_hash
36
+ ON actor_token_records(token_hash)
37
+ WHERE status = 'active'`,
38
+ );
39
+ }
@@ -39,6 +39,7 @@ export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-me
39
39
  export { migrateGuardianActionSupersession } from './035-guardian-action-supersession.js';
40
40
  export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
41
41
  export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
42
+ export { createActorTokenRecordsTable } from './038-actor-token-records.js';
42
43
  export { createCoreTables } from './100-core-tables.js';
43
44
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
44
45
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -1143,6 +1143,22 @@ export const conversationAssistantAttentionState = sqliteTable('conversation_ass
1143
1143
  index('idx_conv_attn_state_assistant_last_seen').on(table.assistantId, table.lastSeenAssistantMessageAt),
1144
1144
  ]);
1145
1145
 
1146
+ // ── Actor Token Records ──────────────────────────────────────────────
1147
+
1148
+ export const actorTokenRecords = sqliteTable('actor_token_records', {
1149
+ id: text('id').primaryKey(),
1150
+ tokenHash: text('token_hash').notNull(),
1151
+ assistantId: text('assistant_id').notNull(),
1152
+ guardianPrincipalId: text('guardian_principal_id').notNull(),
1153
+ hashedDeviceId: text('hashed_device_id').notNull(),
1154
+ platform: text('platform').notNull(),
1155
+ status: text('status').notNull().default('active'),
1156
+ issuedAt: integer('issued_at').notNull(),
1157
+ expiresAt: integer('expires_at'),
1158
+ createdAt: integer('created_at').notNull(),
1159
+ updatedAt: integer('updated_at').notNull(),
1160
+ });
1161
+
1146
1162
  // ── Scoped Approval Grants ──────────────────────────────────────────
1147
1163
 
1148
1164
  export const scopedApprovalGrants = sqliteTable('scoped_approval_grants', {
@@ -81,3 +81,27 @@ export interface SendOptions {
81
81
  /** Optional assistant scope for multi-assistant channels. */
82
82
  assistantId?: string;
83
83
  }
84
+
85
+ /** Result from a sender digest scan — groups messages by sender for bulk cleanup. */
86
+ export interface SenderDigestEntry {
87
+ id: string;
88
+ displayName: string;
89
+ email: string;
90
+ messageCount: number;
91
+ hasUnsubscribe: boolean;
92
+ newestMessageId: string;
93
+ searchQuery: string;
94
+ messageIds: string[];
95
+ hasMore: boolean;
96
+ }
97
+
98
+ export interface SenderDigestResult {
99
+ senders: SenderDigestEntry[];
100
+ totalScanned: number;
101
+ queryUsed: string;
102
+ }
103
+
104
+ export interface ArchiveResult {
105
+ archived: number;
106
+ truncated?: boolean;
107
+ }
@@ -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,