@vellumai/vellum-gateway 0.7.0 → 0.7.2

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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -1,11 +1,25 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { and, eq, gt } from "drizzle-orm";
2
+ import { and, eq, gt, isNotNull, like } from "drizzle-orm";
3
3
  import { type GatewayDb, getGatewayDb } from "./connection.js";
4
- import { slackActiveThreads, slackSeenEvents } from "./schema.js";
4
+ import {
5
+ contactChannels,
6
+ slackActiveThreads,
7
+ slackLastSeenTs,
8
+ slackSeenEvents,
9
+ } from "./schema.js";
10
+
11
+ const LAST_SEEN_KEY = "global";
12
+
13
+ /** Active thread row exposed for catch-up enumeration on reconnect. */
14
+ export type ActiveThreadRow = {
15
+ threadTs: string;
16
+ channelId: string;
17
+ };
5
18
 
6
19
  /**
7
- * Persistent store for Slack thread tracking and event deduplication.
8
- * Backed by SQLite so state survives gateway restarts.
20
+ * Persistent store for Slack thread tracking, event deduplication, and
21
+ * Socket Mode reconnect-catch-up watermarks. Backed by SQLite so state
22
+ * survives gateway restarts.
9
23
  */
10
24
  export class SlackStore {
11
25
  private db: GatewayDb;
@@ -16,14 +30,24 @@ export class SlackStore {
16
30
 
17
31
  // -- Active threads --
18
32
 
19
- trackThread(threadTs: string, ttlMs: number): void {
33
+ /**
34
+ * Track a thread the bot is participating in so unmentioned replies are
35
+ * forwarded. `channelId` is required so reconnect catch-up can scope
36
+ * `conversations.replies` calls to the thread's channel.
37
+ */
38
+ trackThread(threadTs: string, channelId: string, ttlMs: number): void {
20
39
  const now = Date.now();
21
40
  this.db
22
41
  .insert(slackActiveThreads)
23
- .values({ threadTs, trackedAt: now, expiresAt: now + ttlMs })
42
+ .values({
43
+ threadTs,
44
+ channelId,
45
+ trackedAt: now,
46
+ expiresAt: now + ttlMs,
47
+ })
24
48
  .onConflictDoUpdate({
25
49
  target: slackActiveThreads.threadTs,
26
- set: { trackedAt: now, expiresAt: now + ttlMs },
50
+ set: { channelId, trackedAt: now, expiresAt: now + ttlMs },
27
51
  })
28
52
  .run();
29
53
  }
@@ -43,6 +67,59 @@ export class SlackStore {
43
67
  return row !== undefined;
44
68
  }
45
69
 
70
+ /**
71
+ * Returns all unexpired active threads with a known channel for reconnect
72
+ * catch-up. Rows with a NULL `channel_id` (legacy rows from before the
73
+ * column was introduced) are filtered out.
74
+ */
75
+ listActiveThreadsWithChannel(): ActiveThreadRow[] {
76
+ const now = Date.now();
77
+ const rows = this.db
78
+ .select({
79
+ threadTs: slackActiveThreads.threadTs,
80
+ channelId: slackActiveThreads.channelId,
81
+ })
82
+ .from(slackActiveThreads)
83
+ .where(
84
+ and(
85
+ gt(slackActiveThreads.expiresAt, now),
86
+ isNotNull(slackActiveThreads.channelId),
87
+ ),
88
+ )
89
+ .all();
90
+ return rows
91
+ .filter(
92
+ (row): row is { threadTs: string; channelId: string } =>
93
+ typeof row.channelId === "string" && row.channelId.length > 0,
94
+ )
95
+ .map((row) => ({ threadTs: row.threadTs, channelId: row.channelId }));
96
+ }
97
+
98
+ /**
99
+ * Returns distinct Slack DM channel IDs known to the gateway. Used by
100
+ * reconnect catch-up to recover missed direct messages — DMs always route
101
+ * to the default assistant, so any DM channel the gateway has previously
102
+ * received from is a valid catch-up target.
103
+ */
104
+ listKnownSlackDmChannels(): string[] {
105
+ const rows = this.db
106
+ .select({ externalChatId: contactChannels.externalChatId })
107
+ .from(contactChannels)
108
+ .where(
109
+ and(
110
+ eq(contactChannels.type, "slack"),
111
+ isNotNull(contactChannels.externalChatId),
112
+ like(contactChannels.externalChatId, "D%"),
113
+ ),
114
+ )
115
+ .all();
116
+ const seen = new Set<string>();
117
+ for (const row of rows) {
118
+ if (row.externalChatId) seen.add(row.externalChatId);
119
+ }
120
+ return Array.from(seen);
121
+ }
122
+
46
123
  cleanupExpiredThreads(): number {
47
124
  const now = Date.now();
48
125
  const raw = (this.db as unknown as { $client: Database }).$client;
@@ -53,23 +130,28 @@ export class SlackStore {
53
130
 
54
131
  // -- Event dedup --
55
132
 
56
- markEventSeen(eventId: string, ttlMs: number): void {
133
+ /**
134
+ * Mark a generic dedup key as seen. Callers pass either a Slack `event_id`
135
+ * (live path) or a synthetic `msg:${channel}:${ts}` key (replay path);
136
+ * both flow into the same dedup table so the two paths dedup symmetrically.
137
+ */
138
+ markEventSeen(key: string, ttlMs: number): void {
57
139
  const now = Date.now();
58
140
  this.db
59
141
  .insert(slackSeenEvents)
60
- .values({ eventId, seenAt: now, expiresAt: now + ttlMs })
142
+ .values({ eventId: key, seenAt: now, expiresAt: now + ttlMs })
61
143
  .onConflictDoNothing()
62
144
  .run();
63
145
  }
64
146
 
65
- hasEvent(eventId: string): boolean {
147
+ hasEvent(key: string): boolean {
66
148
  const now = Date.now();
67
149
  const row = this.db
68
150
  .select({ eventId: slackSeenEvents.eventId })
69
151
  .from(slackSeenEvents)
70
152
  .where(
71
153
  and(
72
- eq(slackSeenEvents.eventId, eventId),
154
+ eq(slackSeenEvents.eventId, key),
73
155
  gt(slackSeenEvents.expiresAt, now),
74
156
  ),
75
157
  )
@@ -84,4 +166,55 @@ export class SlackStore {
84
166
  .prepare("DELETE FROM slack_seen_events WHERE expires_at < ?")
85
167
  .run(now).changes;
86
168
  }
169
+
170
+ // -- Catch-up watermark --
171
+
172
+ /**
173
+ * Latest accepted Slack event timestamp (`<seconds>.<microseconds>`),
174
+ * persisted across reconnects so catch-up knows where to resume from.
175
+ * Returns undefined on first ever start — callers should bootstrap to
176
+ * "now" and skip catch-up.
177
+ */
178
+ getLastSeenTs(): string | undefined {
179
+ const row = this.db
180
+ .select({ ts: slackLastSeenTs.ts })
181
+ .from(slackLastSeenTs)
182
+ .where(eq(slackLastSeenTs.key, LAST_SEEN_KEY))
183
+ .get();
184
+ return row?.ts;
185
+ }
186
+
187
+ /**
188
+ * Advances the watermark to `ts` only if it is greater than the persisted
189
+ * value, so out-of-order live + replay events cannot push it backwards.
190
+ * Comparison is numeric (Slack ts is a `<secs>.<micros>` string but lex
191
+ * order matches numeric order until the seconds component grows in width
192
+ * — well past 2286 — so string comparison is safe in practice).
193
+ */
194
+ setLastSeenTsIfGreater(ts: string): void {
195
+ if (!ts) return;
196
+ const now = Date.now();
197
+ const current = this.getLastSeenTs();
198
+ if (current && compareSlackTs(ts, current) <= 0) return;
199
+ this.db
200
+ .insert(slackLastSeenTs)
201
+ .values({ key: LAST_SEEN_KEY, ts, updatedAt: now })
202
+ .onConflictDoUpdate({
203
+ target: slackLastSeenTs.key,
204
+ set: { ts, updatedAt: now },
205
+ })
206
+ .run();
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Numeric comparator for Slack timestamps. Returns negative/zero/positive
212
+ * mirroring `Number(a) - Number(b)`. Falls back to string comparison if
213
+ * either value fails to parse.
214
+ */
215
+ export function compareSlackTs(a: string, b: string): number {
216
+ const na = Number(a);
217
+ const nb = Number(b);
218
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
219
+ return a < b ? -1 : a > b ? 1 : 0;
87
220
  }
@@ -9,17 +9,9 @@
9
9
  "description": "Automatically trigger conversation analysis on the same cadence as memory extraction (batch threshold, idle debounce, end-of-conversation). The analysis agent has full tool access and writes back to memory and skills without user approval.",
10
10
  "defaultEnabled": false
11
11
  },
12
- {
13
- "id": "browser",
14
- "scope": "assistant",
15
- "key": "browser",
16
- "label": "Browser",
17
- "description": "Enable browser skill prerequisites section in the system prompt",
18
- "defaultEnabled": true
19
- },
20
12
  {
21
13
  "id": "user-hosted-enabled",
22
- "scope": "macos",
14
+ "scope": "client",
23
15
  "key": "user-hosted-enabled",
24
16
  "label": "User Hosted Enabled",
25
17
  "description": "Enable user-hosted onboarding flow",
@@ -27,7 +19,7 @@
27
19
  },
28
20
  {
29
21
  "id": "local-docker-enabled",
30
- "scope": "macos",
22
+ "scope": "client",
31
23
  "key": "local-docker-enabled",
32
24
  "label": "Local Docker Mode",
33
25
  "description": "When enabled, the Local hosting option uses Docker under the hood for sandboxed execution, hiding the separate Docker card",
@@ -41,33 +33,17 @@
41
33
  "description": "Enable the entire email integration: email CLI commands, email channel, domain registration, sequences, email readiness probes, invite adapters, and the email-setup skill",
42
34
  "defaultEnabled": false
43
35
  },
44
- {
45
- "id": "app-builder-multifile",
46
- "scope": "assistant",
47
- "key": "app-builder-multifile",
48
- "label": "App Builder Multi-file",
49
- "description": "Enable multi-file TSX app creation with esbuild compilation instead of single-HTML apps",
50
- "defaultEnabled": true
51
- },
52
- {
53
- "id": "mobile-pairing",
54
- "scope": "macos",
55
- "key": "mobile-pairing",
56
- "label": "Mobile Pairing",
57
- "description": "Show the Mobile (iOS) pairing card in Settings > Account",
58
- "defaultEnabled": false
59
- },
60
36
  {
61
37
  "id": "settings-developer-nav",
62
- "scope": "assistant",
38
+ "scope": "client",
63
39
  "key": "settings-developer-nav",
64
40
  "label": "Settings Developer Nav",
65
41
  "description": "Control Developer nav visibility in macOS settings",
66
- "defaultEnabled": true
42
+ "defaultEnabled": false
67
43
  },
68
44
  {
69
45
  "id": "developer-menu-items",
70
- "scope": "macos",
46
+ "scope": "client",
71
47
  "key": "developer-menu-items",
72
48
  "label": "Developer Menu Items",
73
49
  "description": "Show Component Gallery and Replay Onboarding in the menu bar",
@@ -105,54 +81,6 @@
105
81
  "description": "Surface credential grant and audit inspection endpoints for reviewing active grants and access logs",
106
82
  "defaultEnabled": false
107
83
  },
108
- {
109
- "id": "ces-managed-sidecar",
110
- "scope": "assistant",
111
- "key": "ces-managed-sidecar",
112
- "label": "CES Managed Sidecar Transport",
113
- "description": "Use managed sidecar transport for CES communication when running in a containerized environment",
114
- "defaultEnabled": true
115
- },
116
- {
117
- "id": "ces-credential-backend",
118
- "scope": "assistant",
119
- "key": "ces-credential-backend",
120
- "label": "CES Credential Backend",
121
- "description": "Route credential reads and writes through the CES process instead of accessing the encrypted store directly",
122
- "defaultEnabled": true
123
- },
124
- {
125
- "id": "settings-billing",
126
- "scope": "macos",
127
- "key": "settings-billing",
128
- "label": "Billing Settings Tab",
129
- "description": "Show the Billing tab in Settings when signed in, displaying credits balance and top-up",
130
- "defaultEnabled": true
131
- },
132
- {
133
- "id": "referral-codes",
134
- "scope": "macos",
135
- "key": "referral-codes",
136
- "label": "Referral Codes",
137
- "description": "Surface the Earn Credits referral entry points (sidebar drawer row and Billing tab button) that open the referral modal",
138
- "defaultEnabled": true
139
- },
140
- {
141
- "id": "managed-sign-in",
142
- "scope": "macos",
143
- "key": "managed-sign-in",
144
- "label": "Managed Sign In",
145
- "description": "Enable managed (organization-hosted) sign-in flow in the onboarding experience",
146
- "defaultEnabled": true
147
- },
148
- {
149
- "id": "conversation-starters",
150
- "scope": "assistant",
151
- "key": "conversation-starters",
152
- "label": "Recommended Starts",
153
- "description": "Show a curated row of recommended starting actions on the empty conversation page, ordered by relevance as confident first-click options",
154
- "defaultEnabled": true
155
- },
156
84
  {
157
85
  "id": "deploy-to-vercel",
158
86
  "scope": "assistant",
@@ -179,7 +107,7 @@
179
107
  },
180
108
  {
181
109
  "id": "quick-input",
182
- "scope": "macos",
110
+ "scope": "client",
183
111
  "key": "quick-input",
184
112
  "label": "Quick Input",
185
113
  "description": "Enable the Quick Input popover on right-click of the menu bar icon",
@@ -187,43 +115,19 @@
187
115
  },
188
116
  {
189
117
  "id": "expand-completed-steps",
190
- "scope": "macos",
118
+ "scope": "client",
191
119
  "key": "expand-completed-steps",
192
120
  "label": "Expand Completed Steps",
193
121
  "description": "Auto-expand completed tool call step groups instead of showing them collapsed",
194
122
  "defaultEnabled": false
195
123
  },
196
- {
197
- "id": "show-thinking-blocks",
198
- "scope": "macos",
199
- "key": "show-thinking-blocks",
200
- "label": "Show Thinking Blocks",
201
- "description": "Display the assistant's thinking/reasoning inline in chat messages as collapsible blocks",
202
- "defaultEnabled": true
203
- },
204
- {
205
- "id": "inline-skill-commands",
206
- "scope": "assistant",
207
- "key": "inline-skill-commands",
208
- "label": "Inline Skill Command Expansion",
209
- "description": "Enable secure inline skill command expansion via !`command` syntax, with version-pinned approval and sandboxed execution at skill load time",
210
- "defaultEnabled": true
211
- },
212
- {
213
- "id": "channel-voice-transcription",
214
- "scope": "assistant",
215
- "key": "channel-voice-transcription",
216
- "label": "Channel Voice Transcription",
217
- "description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
218
- "defaultEnabled": true
219
- },
220
124
  {
221
125
  "id": "sounds",
222
126
  "scope": "assistant",
223
127
  "key": "sounds",
224
128
  "label": "Sounds",
225
129
  "description": "Enable the Sounds tab in Settings and all app sound playback (event sounds, random ambient sounds)",
226
- "defaultEnabled": true
130
+ "defaultEnabled": false
227
131
  },
228
132
  {
229
133
  "id": "message-tts",
@@ -239,7 +143,7 @@
239
143
  "key": "backward-releases",
240
144
  "label": "Backward Releases",
241
145
  "description": "Show older versions in the version picker, allowing rollback to previous releases",
242
- "defaultEnabled": true
146
+ "defaultEnabled": false
243
147
  },
244
148
  {
245
149
  "id": "voice-mode",
@@ -267,7 +171,7 @@
267
171
  },
268
172
  {
269
173
  "id": "teleport",
270
- "scope": "macos",
174
+ "scope": "client",
271
175
  "key": "teleport",
272
176
  "label": "Teleport",
273
177
  "description": "Enable teleport UI in General settings for moving assistants between hosting environments",
@@ -275,20 +179,12 @@
275
179
  },
276
180
  {
277
181
  "id": "apple-container",
278
- "scope": "macos",
182
+ "scope": "client",
279
183
  "key": "apple-container",
280
184
  "label": "Apple Container",
281
185
  "description": "Enable assistant sandboxing via Apple Containers on macOS 26+, providing a lightweight native sandbox without third-party dependencies",
282
186
  "defaultEnabled": false
283
187
  },
284
- {
285
- "id": "tool-result-truncation",
286
- "scope": "assistant",
287
- "key": "tool-result-truncation",
288
- "label": "Post-Turn Tool Result Truncation",
289
- "description": "Truncate large tool results after each assistant turn, persisting full content to disk for on-demand re-reads",
290
- "defaultEnabled": true
291
- },
292
188
  {
293
189
  "id": "managed-gemini-embeddings-enabled",
294
190
  "scope": "assistant",
@@ -299,36 +195,20 @@
299
195
  },
300
196
  {
301
197
  "id": "fork-from-message",
302
- "scope": "macos",
198
+ "scope": "client",
303
199
  "key": "fork-from-message",
304
200
  "label": "Fork from Message",
305
201
  "description": "Show the 'Fork from here' option in message overflow menus",
306
202
  "defaultEnabled": false
307
203
  },
308
- {
309
- "id": "onboarding-pre-chat",
310
- "scope": "macos",
311
- "key": "onboarding-pre-chat",
312
- "label": "Pre-Chat Onboarding Flow",
313
- "description": "Gates the 3-screen pre-chat onboarding flow (tools, tasks/tone, name exchange) shown before the first conversation",
314
- "defaultEnabled": true
315
- },
316
204
  {
317
205
  "id": "home-tab",
318
- "scope": "macos",
206
+ "scope": "client",
319
207
  "key": "home-tab",
320
208
  "label": "Home Tab",
321
209
  "description": "Replace the knowledge graph top-level tab with a new Home page showing relationship progression, facts, and capability tiers",
322
210
  "defaultEnabled": false
323
211
  },
324
- {
325
- "id": "channel-avatar-sync",
326
- "scope": "assistant",
327
- "key": "channel-avatar-sync",
328
- "label": "Channel Avatar Sync",
329
- "description": "Automatically sync the assistant's avatar to connected channels (Slack) when the avatar changes or a new channel is connected",
330
- "defaultEnabled": true
331
- },
332
212
  {
333
213
  "id": "meet",
334
214
  "scope": "assistant",
@@ -337,29 +217,21 @@
337
217
  "description": "Enables the Google Meet joining bot and the meet-join skill.",
338
218
  "defaultEnabled": false
339
219
  },
340
- {
341
- "id": "compaction-v2",
342
- "scope": "assistant",
343
- "key": "compaction-v2",
344
- "label": "Compaction v2",
345
- "description": "Boundary-message-based compaction pipeline with forked prompt-cache-shared summarization and microcompact pre-pass. Replaces the four-tier overflow reducer and cursor-based storage.",
346
- "defaultEnabled": false
347
- },
348
220
  {
349
221
  "id": "scroll-debug-overlay",
350
- "scope": "macos",
222
+ "scope": "client",
351
223
  "key": "scroll-debug-overlay",
352
224
  "label": "Scroll Debug Overlay",
353
225
  "description": "Show a live HUD in the top-right of the conversation with scroll geometry, velocity, update rate, and anchor-preserver activity. Developer diagnostic for investigating scroll jank.",
354
226
  "defaultEnabled": false
355
227
  },
356
228
  {
357
- "id": "message-height-cache",
358
- "scope": "macos",
359
- "key": "message-height-cache",
360
- "label": "Message Height Cache",
361
- "description": "Swap the transcript's LazyVStack for a plain VStack so scrollContentHeight is the true sum of row heights (no LazyVStack estimator drift). Row frames are not pinned — the earlier frame-pinning approach was removed because it caused overlap when rows grew past their first measurement (streaming, expanding thinking blocks). Tradeoff: eager layout — every row measures up-front, which can stall for many seconds to minutes on very long conversations.",
362
- "defaultEnabled": true
229
+ "id": "coding-agents-panel",
230
+ "scope": "client",
231
+ "key": "coding-agents-panel",
232
+ "label": "Coding Agents Panel",
233
+ "description": "Gate native macOS Coding Agents panel entry points and inline ACP session deep links.",
234
+ "defaultEnabled": false
363
235
  },
364
236
  {
365
237
  "id": "compaction-playground",
@@ -378,11 +250,27 @@
378
250
  "defaultEnabled": false
379
251
  },
380
252
  {
381
- "id": "inference-profiles",
253
+ "id": "account-deletion",
254
+ "scope": "client",
255
+ "key": "account-deletion",
256
+ "label": "Account Deletion",
257
+ "description": "Surfaces the user-initiated account deletion flow in client settings.",
258
+ "defaultEnabled": false
259
+ },
260
+ {
261
+ "id": "app-control",
262
+ "scope": "assistant",
263
+ "key": "app-control",
264
+ "label": "App Control",
265
+ "description": "Enable the app-control skill (per-app screenshot + raw input bypassing AX tree)",
266
+ "defaultEnabled": false
267
+ },
268
+ {
269
+ "id": "analyze-conversation",
382
270
  "scope": "assistant",
383
- "key": "inference-profiles",
384
- "label": "Inference Profiles",
385
- "description": "Show inference profile picker in chat, active profile dropdown, Manage Profiles button, and per-task overrides link in Settings",
271
+ "key": "analyze-conversation",
272
+ "label": "Analyze Conversation",
273
+ "description": "Show the 'Analyze' / 'Analyze conversation' option in conversation context menus and the conversation title actions dropdown.",
386
274
  "defaultEnabled": false
387
275
  }
388
276
  ]
@@ -1,5 +1,10 @@
1
+ import { existsSync } from "node:fs";
1
2
  import type { GatewayConfig } from "../config.js";
3
+ import { ipcCallAssistant } from "../ipc/assistant-client.js";
4
+ import { resolveIpcSocketPath } from "../ipc/socket-path.js";
5
+ import { ContactStore } from "../db/contact-store.js";
2
6
  import { getLogger } from "../logger.js";
7
+ import { canonicalizeInboundIdentity } from "../verification/identity.js";
3
8
  import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
4
9
  import type { RouteResult } from "../routing/types.js";
5
10
  import {
@@ -8,12 +13,14 @@ import {
8
13
  } from "../runtime/client.js";
9
14
  import type { RuntimeInboundResponse } from "../runtime/client.js";
10
15
  import type { GatewayInboundEvent } from "../types.js";
16
+ import { tryTextVerificationIntercept } from "../verification/text-verification.js";
11
17
 
12
18
  const log = getLogger("handle-inbound");
13
19
 
14
20
  export type InboundResult = {
15
21
  forwarded: boolean;
16
22
  rejected: boolean;
23
+ verificationIntercepted?: boolean;
17
24
  runtimeResponse?: RuntimeInboundResponse;
18
25
  rejectionReason?: string;
19
26
  };
@@ -76,6 +83,37 @@ export async function handleInbound(
76
83
  }
77
84
 
78
85
  const displayName = event.actor.displayName || event.actor.username;
86
+
87
+ // ── Text verification intercept ──
88
+ // Must run before forwardToRuntime so the assistant never sees
89
+ // verification code messages. Both success and failure short-circuit.
90
+ const verificationResult = await tryTextVerificationIntercept({
91
+ sourceChannel: event.sourceChannel,
92
+ messageContent: event.message.content,
93
+ actorExternalUserId: event.actor.actorExternalId,
94
+ actorChatId: event.message.conversationExternalId,
95
+ actorDisplayName: event.actor.displayName,
96
+ actorUsername: event.actor.username,
97
+ replyCallbackUrl: options?.replyCallbackUrl,
98
+ assistantId: routing.assistantId,
99
+ });
100
+
101
+ if (verificationResult.intercepted) {
102
+ log.info(
103
+ {
104
+ sourceChannel: event.sourceChannel,
105
+ outcome: verificationResult.outcome,
106
+ trustClass: verificationResult.trustClass,
107
+ },
108
+ "Text verification intercepted — not forwarding to runtime",
109
+ );
110
+ return {
111
+ forwarded: false,
112
+ rejected: false,
113
+ verificationIntercepted: true,
114
+ };
115
+ }
116
+
79
117
  const transportHints = normalizeTransportHints(
80
118
  options?.transportMetadata?.hints,
81
119
  );
@@ -132,6 +170,19 @@ export async function handleInbound(
132
170
  "Inbound event forwarded to runtime",
133
171
  );
134
172
 
173
+ // ── Contact channel interaction tracking (dual-write) ──
174
+ // Reads from the assistant DB (source of truth during migration),
175
+ // writes to both assistant DB and gateway DB. Uses ipcCallAssistant
176
+ // directly (resolves to undefined on failure, never throws) so
177
+ // socket errors cannot leak as unhandled rejections in tests.
178
+ if (!response.denied) {
179
+ // Fire-and-forget: detach from current async context so pending
180
+ // IPC socket operations cannot leak into test runners.
181
+ void touchContactChannelStats(event, response.duplicate).catch(
182
+ () => {},
183
+ );
184
+ }
185
+
135
186
  return { forwarded: true, rejected: false, runtimeResponse: response };
136
187
  } catch (err) {
137
188
  // Let CircuitBreakerOpenError propagate so webhook handlers can
@@ -146,3 +197,75 @@ export async function handleInbound(
146
197
  return { forwarded: false, rejected: false };
147
198
  }
148
199
  }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Contact channel interaction tracking (dual-write helper)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ interface DbProxyResult {
206
+ rows?: Array<Record<string, unknown>>;
207
+ }
208
+
209
+ /**
210
+ * Look up the contact channel in the assistant DB and dual-write
211
+ * interaction stats to both the assistant and gateway databases.
212
+ *
213
+ * Uses ipcCallAssistant directly (resolves to undefined on failure)
214
+ * so socket errors cannot surface as unhandled rejections.
215
+ */
216
+ async function touchContactChannelStats(
217
+ event: GatewayInboundEvent,
218
+ duplicate: boolean,
219
+ ): Promise<void> {
220
+ // Skip if the assistant IPC socket is not available (e.g. in tests).
221
+ const { path: socketPath } = resolveIpcSocketPath("assistant");
222
+ if (!existsSync(socketPath)) return;
223
+
224
+ const canonicalActorId =
225
+ canonicalizeInboundIdentity(
226
+ event.sourceChannel,
227
+ event.actor.actorExternalId,
228
+ ) ?? event.actor.actorExternalId;
229
+
230
+ // Look up channel in assistant DB (source of truth), with
231
+ // externalChatId fallback for legacy/imported contacts.
232
+ let result = (await ipcCallAssistant("db_proxy", {
233
+ sql: "SELECT id FROM contact_channels WHERE type = ? AND external_user_id = ? LIMIT 1",
234
+ mode: "query",
235
+ bind: [event.sourceChannel, canonicalActorId],
236
+ })) as DbProxyResult | undefined;
237
+
238
+ if (!result?.rows?.length) {
239
+ result = (await ipcCallAssistant("db_proxy", {
240
+ sql: "SELECT id FROM contact_channels WHERE type = ? AND external_chat_id = ? LIMIT 1",
241
+ mode: "query",
242
+ bind: [event.sourceChannel, event.message.conversationExternalId],
243
+ })) as DbProxyResult | undefined;
244
+ }
245
+
246
+ if (!result?.rows?.length) return;
247
+
248
+ const channelId = result.rows[0].id as string;
249
+ const now = Date.now();
250
+
251
+ // Assistant DB writes
252
+ await ipcCallAssistant("db_proxy", {
253
+ sql: "UPDATE contact_channels SET last_seen_at = ?, updated_at = ? WHERE id = ?",
254
+ mode: "run",
255
+ bind: [now, now, channelId],
256
+ });
257
+ if (!duplicate) {
258
+ await ipcCallAssistant("db_proxy", {
259
+ sql: "UPDATE contact_channels SET last_interaction = ?, interaction_count = interaction_count + 1, updated_at = ? WHERE id = ?",
260
+ mode: "run",
261
+ bind: [now, now, channelId],
262
+ });
263
+ }
264
+
265
+ // Gateway DB writes
266
+ const store = new ContactStore();
267
+ store.touchChannelLastSeen(channelId);
268
+ if (!duplicate) {
269
+ store.touchContactInteraction(channelId);
270
+ }
271
+ }