@vellumai/assistant 0.3.19 → 0.3.20

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 (189) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  7. package/src/__tests__/approval-primitive.test.ts +540 -0
  8. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  9. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  10. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  11. package/src/__tests__/call-controller.test.ts +439 -108
  12. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  13. package/src/__tests__/cli.test.ts +42 -1
  14. package/src/__tests__/config-schema.test.ts +11 -127
  15. package/src/__tests__/config-watcher.test.ts +0 -8
  16. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  17. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  18. package/src/__tests__/diff.test.ts +22 -0
  19. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  20. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  21. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  22. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  23. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  24. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  25. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  26. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  27. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  28. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  29. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  30. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  31. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  32. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  33. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  34. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  35. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  36. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  37. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  38. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  39. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  40. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  41. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  42. package/src/__tests__/system-prompt.test.ts +1 -1
  43. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  44. package/src/__tests__/terminal-tools.test.ts +2 -93
  45. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  46. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  47. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  48. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  49. package/src/agent/loop.ts +36 -1
  50. package/src/approvals/approval-primitive.ts +381 -0
  51. package/src/approvals/guardian-decision-primitive.ts +191 -0
  52. package/src/calls/call-controller.ts +252 -209
  53. package/src/calls/call-domain.ts +44 -6
  54. package/src/calls/guardian-dispatch.ts +48 -0
  55. package/src/calls/types.ts +1 -1
  56. package/src/calls/voice-session-bridge.ts +46 -30
  57. package/src/cli/core-commands.ts +0 -4
  58. package/src/cli.ts +76 -34
  59. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  60. package/src/config/assistant-feature-flags.ts +162 -0
  61. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  62. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  63. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  64. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  65. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  66. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  67. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  68. package/src/config/core-schema.ts +1 -1
  69. package/src/config/env-registry.ts +10 -0
  70. package/src/config/feature-flag-registry.json +61 -0
  71. package/src/config/loader.ts +22 -1
  72. package/src/config/sandbox-schema.ts +0 -39
  73. package/src/config/schema.ts +6 -2
  74. package/src/config/skill-state.ts +34 -0
  75. package/src/config/skills-schema.ts +0 -1
  76. package/src/config/skills.ts +9 -0
  77. package/src/config/system-prompt.ts +110 -46
  78. package/src/config/templates/SOUL.md +1 -1
  79. package/src/config/types.ts +19 -1
  80. package/src/config/vellum-skills/catalog.json +1 -1
  81. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  82. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  83. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  84. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  86. package/src/daemon/config-watcher.ts +0 -1
  87. package/src/daemon/daemon-control.ts +1 -1
  88. package/src/daemon/guardian-invite-intent.ts +124 -0
  89. package/src/daemon/handlers/avatar.ts +68 -0
  90. package/src/daemon/handlers/browser.ts +2 -2
  91. package/src/daemon/handlers/guardian-actions.ts +120 -0
  92. package/src/daemon/handlers/index.ts +4 -0
  93. package/src/daemon/handlers/sessions.ts +19 -0
  94. package/src/daemon/handlers/shared.ts +3 -1
  95. package/src/daemon/install-cli-launchers.ts +58 -13
  96. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  97. package/src/daemon/ipc-contract/sessions.ts +8 -2
  98. package/src/daemon/ipc-contract/settings.ts +25 -2
  99. package/src/daemon/ipc-contract-inventory.json +10 -0
  100. package/src/daemon/ipc-contract.ts +4 -0
  101. package/src/daemon/lifecycle.ts +6 -2
  102. package/src/daemon/main.ts +1 -0
  103. package/src/daemon/server.ts +1 -0
  104. package/src/daemon/session-lifecycle.ts +52 -7
  105. package/src/daemon/session-memory.ts +45 -0
  106. package/src/daemon/session-process.ts +258 -432
  107. package/src/daemon/session-runtime-assembly.ts +12 -0
  108. package/src/daemon/session-skill-tools.ts +14 -1
  109. package/src/daemon/session-tool-setup.ts +5 -0
  110. package/src/daemon/session.ts +11 -0
  111. package/src/daemon/tool-side-effects.ts +35 -9
  112. package/src/index.ts +0 -2
  113. package/src/memory/conversation-display-order-migration.ts +44 -0
  114. package/src/memory/conversation-queries.ts +2 -0
  115. package/src/memory/conversation-store.ts +91 -0
  116. package/src/memory/db-init.ts +5 -1
  117. package/src/memory/embedding-local.ts +13 -8
  118. package/src/memory/guardian-action-store.ts +125 -2
  119. package/src/memory/ingress-invite-store.ts +95 -1
  120. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  121. package/src/memory/migrations/index.ts +2 -1
  122. package/src/memory/schema.ts +5 -1
  123. package/src/memory/scoped-approval-grants.ts +14 -5
  124. package/src/messaging/providers/slack/client.ts +12 -0
  125. package/src/messaging/providers/slack/types.ts +5 -0
  126. package/src/notifications/decision-engine.ts +49 -12
  127. package/src/notifications/emit-signal.ts +7 -0
  128. package/src/notifications/signal.ts +7 -0
  129. package/src/notifications/thread-seed-composer.ts +2 -1
  130. package/src/runtime/channel-approval-types.ts +16 -6
  131. package/src/runtime/channel-approvals.ts +19 -15
  132. package/src/runtime/channel-invite-transport.ts +85 -0
  133. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  134. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  135. package/src/runtime/guardian-action-message-composer.ts +30 -0
  136. package/src/runtime/guardian-decision-types.ts +91 -0
  137. package/src/runtime/http-server.ts +23 -1
  138. package/src/runtime/ingress-service.ts +22 -0
  139. package/src/runtime/invite-redemption-service.ts +181 -0
  140. package/src/runtime/invite-redemption-templates.ts +39 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  143. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  144. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  145. package/src/runtime/routes/pairing-routes.ts +4 -0
  146. package/src/security/encrypted-store.ts +31 -17
  147. package/src/security/keychain.ts +176 -2
  148. package/src/security/secure-keys.ts +97 -0
  149. package/src/security/tool-approval-digest.ts +1 -1
  150. package/src/tools/browser/browser-execution.ts +2 -2
  151. package/src/tools/browser/browser-manager.ts +46 -32
  152. package/src/tools/browser/browser-screencast.ts +2 -2
  153. package/src/tools/calls/call-start.ts +1 -1
  154. package/src/tools/executor.ts +22 -17
  155. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  156. package/src/tools/skills/load.ts +22 -8
  157. package/src/tools/system/avatar-generator.ts +119 -0
  158. package/src/tools/system/navigate-settings.ts +65 -0
  159. package/src/tools/system/open-system-settings.ts +75 -0
  160. package/src/tools/system/voice-config.ts +121 -32
  161. package/src/tools/terminal/backends/native.ts +40 -19
  162. package/src/tools/terminal/backends/types.ts +3 -3
  163. package/src/tools/terminal/parser.ts +1 -1
  164. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  165. package/src/tools/terminal/sandbox.ts +1 -12
  166. package/src/tools/terminal/shell.ts +3 -31
  167. package/src/tools/tool-approval-handler.ts +141 -3
  168. package/src/tools/tool-manifest.ts +6 -0
  169. package/src/tools/types.ts +6 -0
  170. package/src/util/diff.ts +36 -13
  171. package/Dockerfile.sandbox +0 -5
  172. package/src/__tests__/doordash-client.test.ts +0 -187
  173. package/src/__tests__/doordash-session.test.ts +0 -154
  174. package/src/__tests__/signup-e2e.test.ts +0 -354
  175. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  176. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  177. package/src/cli/doordash.ts +0 -1057
  178. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  179. package/src/config/templates/LOOKS.md +0 -25
  180. package/src/doordash/cart-queries.ts +0 -787
  181. package/src/doordash/client.ts +0 -1016
  182. package/src/doordash/order-queries.ts +0 -85
  183. package/src/doordash/queries.ts +0 -13
  184. package/src/doordash/query-extractor.ts +0 -94
  185. package/src/doordash/search-queries.ts +0 -203
  186. package/src/doordash/session.ts +0 -84
  187. package/src/doordash/store-queries.ts +0 -246
  188. package/src/doordash/types.ts +0 -367
  189. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -66,7 +66,7 @@ const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
66
66
  // Helpers
67
67
  // ---------------------------------------------------------------------------
68
68
 
69
- function hashToken(rawToken: string): string {
69
+ export function hashToken(rawToken: string): string {
70
70
  return createHash('sha256').update(rawToken).digest('hex');
71
71
  }
72
72
 
@@ -268,6 +268,12 @@ export function redeemInvite(params: {
268
268
  return { error: 'invite_max_uses_reached' };
269
269
  }
270
270
 
271
+ // Enforce channel-scoped redemption: when the caller specifies a channel, it
272
+ // must match the channel the invite was created for.
273
+ if (params.sourceChannel && params.sourceChannel !== invite.sourceChannel) {
274
+ return { error: 'invite_channel_mismatch' };
275
+ }
276
+
271
277
  const newUseCount = invite.useCount + 1;
272
278
  const newStatus = newUseCount >= invite.maxUses ? 'redeemed' : 'active';
273
279
 
@@ -323,6 +329,94 @@ export function redeemInvite(params: {
323
329
  return { invite: updatedInvite, member: rowToMember(memberRow) };
324
330
  }
325
331
 
332
+ // ---------------------------------------------------------------------------
333
+ // recordInviteUse — consume one use without creating a member row
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * Increment an invite's use count and record redemption metadata without
338
+ * inserting a new member row. Used when reactivating an existing inactive
339
+ * member via invite — the member row already exists and just needs an
340
+ * update, so the transactional INSERT in `redeemInvite` would hit a
341
+ * unique-key constraint.
342
+ *
343
+ * Returns `true` if the use was recorded, or `false` if the invite was
344
+ * concurrently revoked/expired (the WHERE clause constrains to
345
+ * `status = 'active'` so a stale write is impossible).
346
+ */
347
+ export function recordInviteUse(params: {
348
+ inviteId: string;
349
+ externalUserId?: string;
350
+ externalChatId?: string;
351
+ }): boolean {
352
+ const db = getDb();
353
+ const now = Date.now();
354
+
355
+ const invite = db
356
+ .select()
357
+ .from(assistantIngressInvites)
358
+ .where(eq(assistantIngressInvites.id, params.inviteId))
359
+ .get();
360
+
361
+ if (!invite) return false;
362
+
363
+ const newUseCount = invite.useCount + 1;
364
+ const newStatus = newUseCount >= invite.maxUses ? 'redeemed' : 'active';
365
+
366
+ // Constrain the update to active invites so a concurrent revoke/expire
367
+ // prevents this write rather than silently overwriting the new status.
368
+ db.update(assistantIngressInvites)
369
+ .set({
370
+ useCount: newUseCount,
371
+ status: newStatus,
372
+ redeemedByExternalUserId: params.externalUserId ?? null,
373
+ redeemedByExternalChatId: params.externalChatId ?? null,
374
+ redeemedAt: now,
375
+ updatedAt: now,
376
+ })
377
+ .where(
378
+ and(
379
+ eq(assistantIngressInvites.id, invite.id),
380
+ eq(assistantIngressInvites.status, 'active'),
381
+ ),
382
+ )
383
+ .run();
384
+
385
+ // Re-read to confirm the update took effect (the WHERE clause constrains
386
+ // to status = 'active', so a concurrent revoke/expire would prevent it).
387
+ const updated = db
388
+ .select({ useCount: assistantIngressInvites.useCount })
389
+ .from(assistantIngressInvites)
390
+ .where(eq(assistantIngressInvites.id, invite.id))
391
+ .get();
392
+
393
+ return !!updated && updated.useCount === newUseCount;
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // markInviteExpired
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /**
401
+ * Transition an invite's status to 'expired' in storage. This is safe to call
402
+ * even if the invite is already expired — the WHERE clause scopes the update
403
+ * to 'active' rows so it becomes a no-op in that case.
404
+ */
405
+ export function markInviteExpired(inviteId: string): void {
406
+ const db = getDb();
407
+ const now = Date.now();
408
+
409
+ db.update(assistantIngressInvites)
410
+ .set({ status: 'expired', updatedAt: now })
411
+ .where(
412
+ and(
413
+ eq(assistantIngressInvites.id, inviteId),
414
+ eq(assistantIngressInvites.status, 'active'),
415
+ ),
416
+ )
417
+ .run();
418
+ }
419
+
326
420
  // ---------------------------------------------------------------------------
327
421
  // findByTokenHash
328
422
  // ---------------------------------------------------------------------------
@@ -0,0 +1,23 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add supersession metadata columns to guardian_action_requests and
5
+ * create an index for efficient pending-request lookups by call session.
6
+ *
7
+ * - superseded_by_request_id: links to the request that replaced this one
8
+ * - superseded_at: timestamp when supersession occurred
9
+ * - Index (call_session_id, status, created_at DESC) for fast lookups of
10
+ * the most recent pending request per call session
11
+ *
12
+ * The existing expired_reason column already supports 'superseded' as a
13
+ * value — this migration adds the structural metadata to track the
14
+ * supersession chain.
15
+ */
16
+ export function migrateGuardianActionSupersession(database: DrizzleDb): void {
17
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN superseded_by_request_id TEXT`); } catch { /* already exists */ }
18
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN superseded_at INTEGER`); } catch { /* already exists */ }
19
+
20
+ database.run(
21
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_action_requests_session_status_created ON guardian_action_requests(call_session_id, status, created_at DESC)`,
22
+ );
23
+ }
@@ -33,9 +33,10 @@ export { migrateGuardianActionFollowup } from './030-guardian-action-followup.js
33
33
  export { migrateGuardianVerificationPurpose } from './030-guardian-verification-purpose.js';
34
34
  export { migrateConversationsThreadTypeIndex } from './031-conversations-thread-type-index.js';
35
35
  export { migrateGuardianDeliveryConversationIndex } from './032-guardian-delivery-conversation-index.js';
36
+ export { migrateNotificationDeliveryThreadDecision } from './032-notification-delivery-thread-decision.js';
36
37
  export { createScopedApprovalGrantsTable } from './033-scoped-approval-grants.js';
37
38
  export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-metadata.js';
38
- export { migrateNotificationDeliveryThreadDecision } from './032-notification-delivery-thread-decision.js';
39
+ export { migrateGuardianActionSupersession } from './035-guardian-action-supersession.js';
39
40
  export { createCoreTables } from './100-core-tables.js';
40
41
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
41
42
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -845,9 +845,13 @@ export const guardianActionRequests = sqliteTable('guardian_action_requests', {
845
845
  followupCompletedAt: integer('followup_completed_at'),
846
846
  toolName: text('tool_name'), // tool identity for tool-approval requests
847
847
  inputDigest: text('input_digest'), // canonical SHA-256 digest of tool input
848
+ supersededByRequestId: text('superseded_by_request_id'), // links to the request that replaced this one
849
+ supersededAt: integer('superseded_at'), // epoch ms when supersession occurred
848
850
  createdAt: integer('created_at').notNull(),
849
851
  updatedAt: integer('updated_at').notNull(),
850
- });
852
+ }, (table) => [
853
+ index('idx_guardian_action_requests_session_status_created').on(table.callSessionId, table.status, table.createdAt),
854
+ ]);
851
855
 
852
856
  // ── Guardian Action Deliveries (per-channel delivery tracking) ───────
853
857
 
@@ -11,12 +11,12 @@
11
11
  * - Expired and revoked grants cannot be consumed.
12
12
  */
13
13
 
14
- import { and, eq, lt, sql } from 'drizzle-orm';
14
+ import { and, eq, sql } from 'drizzle-orm';
15
15
  import { v4 as uuid } from 'uuid';
16
16
 
17
+ import { getLogger } from '../util/logger.js';
17
18
  import { getDb, rawChanges } from './db.js';
18
19
  import { scopedApprovalGrants } from './schema.js';
19
- import { getLogger } from '../util/logger.js';
20
20
 
21
21
  const log = getLogger('scoped-approval-grants');
22
22
 
@@ -104,7 +104,7 @@ export interface CreateScopedApprovalGrantParams {
104
104
  expiresAt: string;
105
105
  }
106
106
 
107
- export function createScopedApprovalGrant(params: CreateScopedApprovalGrantParams): ScopedApprovalGrant {
107
+ function createScopedApprovalGrant(params: CreateScopedApprovalGrantParams): ScopedApprovalGrant {
108
108
  const db = getDb();
109
109
  const now = new Date().toISOString();
110
110
  const id = uuid();
@@ -167,7 +167,7 @@ export interface ConsumeByRequestIdResult {
167
167
  * given `requestId` and `assistantId`. Uses compare-and-swap on the
168
168
  * `status` column so concurrent consumers race safely — at most one wins.
169
169
  */
170
- export function consumeScopedApprovalGrantByRequestId(
170
+ function consumeScopedApprovalGrantByRequestId(
171
171
  requestId: string,
172
172
  consumingRequestId: string,
173
173
  assistantId: string,
@@ -280,7 +280,7 @@ export interface ConsumeByToolSignatureResult {
280
280
  * times before giving up. This prevents false denials when multiple matching
281
281
  * grants exist but a concurrent consumer steals the first pick.
282
282
  */
283
- export function consumeScopedApprovalGrantByToolSignature(
283
+ function consumeScopedApprovalGrantByToolSignature(
284
284
  params: ConsumeByToolSignatureParams,
285
285
  ): ConsumeByToolSignatureResult {
286
286
  const db = getDb();
@@ -507,3 +507,12 @@ export function revokeScopedApprovalGrantsForContext(params: RevokeContextParams
507
507
 
508
508
  return count;
509
509
  }
510
+
511
+ // @internal — exposed for tests and the approval-primitive wrapper only.
512
+ // Do not import these from production code outside this package; use the
513
+ // approval-primitive API instead.
514
+ export const _internal = {
515
+ createScopedApprovalGrant,
516
+ consumeScopedApprovalGrantByRequestId,
517
+ consumeScopedApprovalGrantByToolSignature,
518
+ };
@@ -8,6 +8,7 @@
8
8
  import type {
9
9
  SlackApiResponse,
10
10
  SlackAuthTestResponse,
11
+ SlackChatDeleteResponse,
11
12
  SlackConversationHistoryResponse,
12
13
  SlackConversationLeaveResponse,
13
14
  SlackConversationMarkResponse,
@@ -188,6 +189,17 @@ export async function addReaction(
188
189
  });
189
190
  }
190
191
 
192
+ export async function deleteMessage(
193
+ token: string,
194
+ channel: string,
195
+ ts: string,
196
+ ): Promise<SlackChatDeleteResponse> {
197
+ return request<SlackChatDeleteResponse>(token, 'chat.delete', undefined, {
198
+ channel,
199
+ ts,
200
+ });
201
+ }
202
+
191
203
  export async function leaveConversation(
192
204
  token: string,
193
205
  channel: string,
@@ -116,4 +116,9 @@ export type SlackReactionAddResponse = SlackApiResponse;
116
116
 
117
117
  export type SlackConversationLeaveResponse = SlackApiResponse;
118
118
 
119
+ export interface SlackChatDeleteResponse extends SlackApiResponse {
120
+ channel: string;
121
+ ts: string;
122
+ }
123
+
119
124
  export type SlackConversationMarkResponse = SlackApiResponse;
@@ -16,6 +16,7 @@ import { getConfig } from '../config/loader.js';
16
16
  import { createTimeout, extractToolUse, getConfiguredProvider, userMessage } from '../providers/provider-send-message.js';
17
17
  import type { ModelIntent } from '../providers/types.js';
18
18
  import { getLogger } from '../util/logger.js';
19
+ import { composeFallbackCopy } from './copy-composer.js';
19
20
  import { createDecision } from './decisions-store.js';
20
21
  import { getPreferenceSummary } from './preference-summary.js';
21
22
  import type { NotificationSignal, RoutingIntent } from './signal.js';
@@ -251,17 +252,7 @@ function buildFallbackDecision(
251
252
  };
252
253
  }
253
254
 
254
- const copy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
255
- for (const ch of selectedChannels) {
256
- const fallbackBody = isHighUrgencyAction
257
- ? `Action required: ${signal.sourceEventName}`
258
- : signal.sourceEventName;
259
- copy[ch] = {
260
- title: signal.sourceEventName,
261
- body: fallbackBody,
262
- ...(ch === 'telegram' ? { deliveryText: fallbackBody } : {}),
263
- };
264
- }
255
+ const copy = composeFallbackCopy(signal, selectedChannels);
265
256
 
266
257
  return {
267
258
  shouldNotify: true,
@@ -452,7 +443,8 @@ export async function evaluateSignal(
452
443
  const provider = getConfiguredProvider();
453
444
  if (!provider) {
454
445
  log.warn('Configured provider unavailable for notification decision, using fallback');
455
- const decision = buildFallbackDecision(signal, availableChannels);
446
+ let decision = buildFallbackDecision(signal, availableChannels);
447
+ decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
456
448
  decision.persistedDecisionId = persistDecision(signal, decision);
457
449
  return decision;
458
450
  }
@@ -466,6 +458,7 @@ export async function evaluateSignal(
466
458
  decision = buildFallbackDecision(signal, availableChannels);
467
459
  }
468
460
 
461
+ decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
469
462
  decision.persistedDecisionId = persistDecision(signal, decision);
470
463
 
471
464
  return decision;
@@ -600,6 +593,50 @@ export function enforceRoutingIntent(
600
593
  return decision;
601
594
  }
602
595
 
596
+ // ── Conversation affinity enforcement ───────────────────────────────────
597
+
598
+ /**
599
+ * Enforce conversation affinity on a decision.
600
+ *
601
+ * When the signal carries a conversationAffinityHint (per-channel map of
602
+ * conversationId), override the decision's threadActions for those channels
603
+ * to `reuse_existing` with the hinted conversationId. This is a
604
+ * deterministic post-decision guard that prevents the LLM from routing
605
+ * guardian questions for the same call session to different conversations.
606
+ */
607
+ export function enforceConversationAffinity(
608
+ decision: NotificationDecision,
609
+ affinityHint: Partial<Record<string, string>> | undefined,
610
+ ): NotificationDecision {
611
+ if (!affinityHint) return decision;
612
+
613
+ const entries = Object.entries(affinityHint).filter(
614
+ ([, conversationId]) => typeof conversationId === 'string' && conversationId.length > 0,
615
+ );
616
+ if (entries.length === 0) return decision;
617
+
618
+ const enforced = { ...decision };
619
+ const threadActions: Partial<Record<NotificationChannel, ThreadAction>> = {
620
+ ...(decision.threadActions ?? {}),
621
+ };
622
+
623
+ for (const [channel, conversationId] of entries) {
624
+ threadActions[channel as NotificationChannel] = {
625
+ action: 'reuse_existing',
626
+ conversationId: conversationId!,
627
+ };
628
+ }
629
+
630
+ enforced.threadActions = threadActions;
631
+
632
+ log.info(
633
+ { affinityHint },
634
+ 'Conversation affinity enforcement: overrode threadActions for hinted channels',
635
+ );
636
+
637
+ return enforced;
638
+ }
639
+
603
640
  // ── Persistence ────────────────────────────────────────────────────────
604
641
 
605
642
  function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
@@ -133,6 +133,12 @@ export interface EmitSignalParams {
133
133
  routingIntent?: RoutingIntent;
134
134
  /** Free-form hints from the source for the decision engine. */
135
135
  routingHints?: Record<string, unknown>;
136
+ /**
137
+ * Per-channel conversation affinity hint. Forces the decision engine to
138
+ * reuse the specified conversation for the given channel(s), bypassing
139
+ * LLM thread-routing judgment. Keyed by channel name, value is conversationId.
140
+ */
141
+ conversationAffinityHint?: Partial<Record<string, string>>;
136
142
  /** Optional deduplication key. */
137
143
  dedupeKey?: string;
138
144
  /**
@@ -177,6 +183,7 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
177
183
  attentionHints: params.attentionHints,
178
184
  routingIntent: params.routingIntent,
179
185
  routingHints: params.routingHints,
186
+ conversationAffinityHint: params.conversationAffinityHint,
180
187
  };
181
188
 
182
189
  try {
@@ -27,4 +27,11 @@ export interface NotificationSignal {
27
27
  routingIntent?: RoutingIntent;
28
28
  /** Free-form hints from the source for the decision engine (e.g. preferred channels). */
29
29
  routingHints?: Record<string, unknown>;
30
+ /**
31
+ * Per-channel conversation affinity hint. When set, the decision engine
32
+ * must force thread reuse to the specified conversation for that channel,
33
+ * bypassing LLM judgment. Used to enforce deterministic guardian thread
34
+ * affinity within a call session.
35
+ */
36
+ conversationAffinityHint?: Partial<Record<string, string>>;
30
37
  }
@@ -140,7 +140,8 @@ export function composeThreadSeed(
140
140
  const parts: string[] = [];
141
141
  if (copy.title && copy.title !== 'Notification') parts.push(copy.title);
142
142
  if (copy.body) parts.push(copy.body);
143
- if (signal.attentionHints.requiresAction && parts.length > 0) {
143
+ const alreadyMentionsAction = parts.some((part) => /\baction required\b/i.test(part));
144
+ if (signal.attentionHints.requiresAction && parts.length > 0 && !alreadyMentionsAction) {
144
145
  parts.push('Action required.');
145
146
  }
146
147
  if (parts.length > 0) {
@@ -7,6 +7,8 @@
7
7
  * same approval flow can be reused across transports.
8
8
  */
9
9
 
10
+ import type { GuardianDecisionAction } from './guardian-decision-types.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Approval actions
12
14
  // ---------------------------------------------------------------------------
@@ -20,12 +22,20 @@ export interface ApprovalActionOption {
20
22
  label: string;
21
23
  }
22
24
 
23
- /** Default action options presented to users across all channels. */
24
- export const DEFAULT_APPROVAL_ACTIONS: readonly ApprovalActionOption[] = [
25
- { id: 'approve_once', label: 'Approve once' },
26
- { id: 'approve_always', label: 'Approve always' },
27
- { id: 'reject', label: 'Reject' },
28
- ] as const;
25
+ /**
26
+ * Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
27
+ * prompt payloads can be derived from the unified decision action set.
28
+ * The `action` field from GuardianDecisionAction maps to the `id` field
29
+ * on ApprovalActionOption (both are canonical action identifiers).
30
+ */
31
+ export function toApprovalActionOptions(
32
+ actions: GuardianDecisionAction[],
33
+ ): ApprovalActionOption[] {
34
+ return actions.map(a => ({
35
+ id: a.action as ApprovalAction,
36
+ label: a.label,
37
+ }));
38
+ }
29
39
 
30
40
  // ---------------------------------------------------------------------------
31
41
  // Approval prompt
@@ -17,7 +17,8 @@ import type {
17
17
  ApprovalUIMetadata,
18
18
  ChannelApprovalPrompt,
19
19
  } from './channel-approval-types.js';
20
- import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
20
+ import { toApprovalActionOptions } from './channel-approval-types.js';
21
+ import { buildDecisionActions, buildPlainTextFallback } from './guardian-decision-types.js';
21
22
  import * as pendingInteractions from './pending-interactions.js';
22
23
 
23
24
  /** Summary of a pending interaction, used by channel approval flows. */
@@ -69,6 +70,11 @@ export function getApprovalInfoByConversation(conversationId: string): PendingAp
69
70
 
70
71
  /**
71
72
  * Internal helper: turn a PendingApprovalInfo into a ChannelApprovalPrompt.
73
+ *
74
+ * Derives actions from the shared `buildDecisionActions` builder defined in
75
+ * guardian-decision-types.ts, then maps them to the channel-facing
76
+ * `ApprovalActionOption` shape. This ensures channel button sets are always
77
+ * consistent with the unified `GuardianDecisionPrompt` type.
72
78
  */
73
79
  function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApprovalPrompt {
74
80
  const promptText = composeApprovalMessage({
@@ -76,15 +82,11 @@ function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApproval
76
82
  toolName: info.toolName,
77
83
  });
78
84
 
79
- // Hide "approve always" when persistent trust rules are disallowed for this invocation.
80
- const actions = info.persistentDecisionsAllowed === false
81
- ? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
82
- : [...DEFAULT_APPROVAL_ACTIONS];
83
-
84
- // Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
85
- const plainTextFallback = info.persistentDecisionsAllowed === false
86
- ? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
87
- : `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
85
+ const decisionActions = buildDecisionActions({
86
+ persistentDecisionsAllowed: info.persistentDecisionsAllowed,
87
+ });
88
+ const actions = toApprovalActionOptions(decisionActions);
89
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
88
90
 
89
91
  return { promptText, actions, plainTextFallback };
90
92
  }
@@ -199,6 +201,10 @@ export function handleChannelDecision(
199
201
  * Build an approval prompt that includes context about which non-guardian
200
202
  * user is requesting the action. Sent to the guardian's chat so they
201
203
  * can approve or deny on behalf of the requester.
204
+ *
205
+ * Uses the shared `buildDecisionActions` builder with `forGuardianOnBehalf`
206
+ * set to true, which excludes `approve_always` since guardians cannot
207
+ * permanently allowlist tools on behalf of requesters.
202
208
  */
203
209
  export function buildGuardianApprovalPrompt(
204
210
  info: PendingApprovalInfo,
@@ -210,11 +216,9 @@ export function buildGuardianApprovalPrompt(
210
216
  requesterIdentifier,
211
217
  });
212
218
 
213
- // Guardian approvals are always one-time decisions — "approve always"
214
- // doesn't make sense when the guardian is approving on behalf of someone else.
215
- const actions = DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always');
216
-
217
- const plainTextFallback = `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
219
+ const decisionActions = buildDecisionActions({ forGuardianOnBehalf: true });
220
+ const actions = toApprovalActionOptions(decisionActions);
221
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
218
222
 
219
223
  return { promptText, actions, plainTextFallback };
220
224
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Channel invite transport abstraction.
3
+ *
4
+ * Defines a transport interface for building shareable invite links and
5
+ * extracting inbound invite tokens from channel-specific payloads. Each
6
+ * channel (Telegram, SMS, Slack, etc.) registers an adapter that knows
7
+ * how to construct deep links and parse incoming tokens for that channel.
8
+ *
9
+ * The transport layer is intentionally thin: it handles URL construction
10
+ * and token extraction only. Redemption logic lives in
11
+ * `invite-redemption-service.ts`.
12
+ */
13
+
14
+ import type { ChannelId } from '../channels/types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface InviteSharePayload {
21
+ /** The full URL the recipient can open to redeem the invite. */
22
+ url: string;
23
+ /** Human-readable text suitable for display alongside the link. */
24
+ displayText: string;
25
+ }
26
+
27
+ export interface ChannelInviteTransport {
28
+ /** The channel this transport handles. */
29
+ channel: ChannelId;
30
+
31
+ /**
32
+ * Build a shareable invite payload (URL + display text) from a raw token.
33
+ *
34
+ * The raw token is the base64url-encoded secret returned by
35
+ * `ingress-invite-store.createInvite`. The transport wraps it in a
36
+ * channel-specific deep link so the recipient can redeem the invite
37
+ * by clicking/tapping the link.
38
+ */
39
+ buildShareableInvite(params: {
40
+ rawToken: string;
41
+ sourceChannel: ChannelId;
42
+ }): InviteSharePayload;
43
+
44
+ /**
45
+ * Extract an invite token from an inbound channel message.
46
+ *
47
+ * Returns the raw token string (without the `iv_` prefix) if the
48
+ * message contains a valid invite token, or `undefined` otherwise.
49
+ */
50
+ extractInboundToken(params: {
51
+ commandIntent?: Record<string, unknown>;
52
+ content: string;
53
+ sourceMetadata?: Record<string, unknown>;
54
+ }): string | undefined;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Registry
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const registry = new Map<ChannelId, ChannelInviteTransport>();
62
+
63
+ /**
64
+ * Register a channel invite transport. Overwrites any previously registered
65
+ * transport for the same channel.
66
+ */
67
+ export function registerTransport(transport: ChannelInviteTransport): void {
68
+ registry.set(transport.channel, transport);
69
+ }
70
+
71
+ /**
72
+ * Look up the registered transport for a channel. Returns `undefined` when
73
+ * no transport has been registered for the given channel.
74
+ */
75
+ export function getTransport(channel: ChannelId): ChannelInviteTransport | undefined {
76
+ return registry.get(channel);
77
+ }
78
+
79
+ /**
80
+ * Reset the registry. Intended for tests only.
81
+ * @internal
82
+ */
83
+ export function _resetRegistry(): void {
84
+ registry.clear();
85
+ }