@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -1,6 +1,9 @@
1
1
  // Re-export all conversation store functionality from focused sub-modules.
2
2
  // Existing imports from this file continue to work without changes.
3
3
 
4
+ import { ensureDisplayOrderMigration } from './conversation-display-order-migration.js';
5
+ import { rawExec, rawGet, rawRun } from './db.js';
6
+
4
7
  export {
5
8
  addMessage,
6
9
  clearAll,
@@ -42,3 +45,91 @@ export {
42
45
  type PaginatedMessagesResult,
43
46
  searchConversations,
44
47
  } from './conversation-queries.js';
48
+
49
+ // Re-export for backward compat — callers that imported ensureColumns from here
50
+ export { ensureDisplayOrderMigration as ensureColumns } from './conversation-display-order-migration.js';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // CRUD functions for display_order and is_pinned
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export function getDisplayOrder(conversationId: string): number | null {
57
+ ensureDisplayOrderMigration();
58
+ const row = rawGet<{ display_order: number | null }>(
59
+ 'SELECT display_order FROM conversations WHERE id = ?',
60
+ conversationId,
61
+ );
62
+ return row?.display_order ?? null;
63
+ }
64
+
65
+ export function setDisplayOrder(conversationId: string, order: number | null): void {
66
+ ensureDisplayOrderMigration();
67
+ rawRun(
68
+ 'UPDATE conversations SET display_order = ? WHERE id = ?',
69
+ order,
70
+ conversationId,
71
+ );
72
+ }
73
+
74
+ export function batchSetDisplayOrders(
75
+ updates: Array<{ id: string; displayOrder: number | null; isPinned: boolean }>,
76
+ ): void {
77
+ ensureDisplayOrderMigration();
78
+ rawExec('BEGIN');
79
+ try {
80
+ for (const update of updates) {
81
+ rawRun(
82
+ 'UPDATE conversations SET display_order = ?, is_pinned = ? WHERE id = ?',
83
+ update.displayOrder,
84
+ update.isPinned ? 1 : 0,
85
+ update.id,
86
+ );
87
+ }
88
+ rawExec('COMMIT');
89
+ } catch (err) {
90
+ rawExec('ROLLBACK');
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ export function setConversationPinned(conversationId: string, isPinned: boolean): void {
96
+ ensureDisplayOrderMigration();
97
+ rawRun(
98
+ 'UPDATE conversations SET is_pinned = ? WHERE id = ?',
99
+ isPinned ? 1 : 0,
100
+ conversationId,
101
+ );
102
+ }
103
+
104
+ export function getConversationDisplayMeta(
105
+ conversationId: string,
106
+ ): { displayOrder: number | null; isPinned: boolean } {
107
+ ensureDisplayOrderMigration();
108
+ const row = rawGet<{ display_order: number | null; is_pinned: number | null }>(
109
+ 'SELECT display_order, is_pinned FROM conversations WHERE id = ?',
110
+ conversationId,
111
+ );
112
+ return {
113
+ displayOrder: row?.display_order ?? null,
114
+ isPinned: (row?.is_pinned ?? 0) === 1,
115
+ };
116
+ }
117
+
118
+ export function getDisplayMetaForConversations(
119
+ conversationIds: string[],
120
+ ): Map<string, { displayOrder: number | null; isPinned: boolean }> {
121
+ ensureDisplayOrderMigration();
122
+ const result = new Map<string, { displayOrder: number | null; isPinned: boolean }>();
123
+ if (conversationIds.length === 0) return result;
124
+ for (const id of conversationIds) {
125
+ const row = rawGet<{ display_order: number | null; is_pinned: number | null }>(
126
+ 'SELECT display_order, is_pinned FROM conversations WHERE id = ?',
127
+ id,
128
+ );
129
+ result.set(id, {
130
+ displayOrder: row?.display_order ?? null,
131
+ isPinned: (row?.is_pinned ?? 0) === 1,
132
+ });
133
+ }
134
+ return result;
135
+ }
@@ -8,12 +8,12 @@ import {
8
8
  createConversationAttentionTables,
9
9
  createCoreIndexes,
10
10
  createCoreTables,
11
- createScopedApprovalGrantsTable,
12
11
  createExternalConversationBindingsTables,
13
12
  createFollowupsTables,
14
13
  createMediaAssetsTables,
15
14
  createMessagesFts,
16
15
  createNotificationTables,
16
+ createScopedApprovalGrantsTable,
17
17
  createSequenceTables,
18
18
  createTasksAndWorkItemsTables,
19
19
  createWatchersAndLogsTables,
@@ -22,6 +22,7 @@ import {
22
22
  migrateConversationsThreadTypeIndex,
23
23
  migrateFkCascadeRebuilds,
24
24
  migrateGuardianActionFollowup,
25
+ migrateGuardianActionSupersession,
25
26
  migrateGuardianActionToolMetadata,
26
27
  migrateGuardianBootstrapToken,
27
28
  migrateGuardianDeliveryConversationIndex,
@@ -107,6 +108,9 @@ export function initializeDb(): void {
107
108
  // 14c2. Guardian action tool-approval metadata columns (tool_name, input_digest)
108
109
  migrateGuardianActionToolMetadata(database);
109
110
 
111
+ // 14c3. Guardian action supersession metadata (superseded_by_request_id, superseded_at) + session lookup index
112
+ migrateGuardianActionSupersession(database);
113
+
110
114
  // 14d. Index on conversations.thread_type for frequent WHERE filters
111
115
  migrateConversationsThreadTypeIndex(database);
112
116
 
@@ -58,24 +58,29 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
58
58
 
59
59
  private async initialize(): Promise<void> {
60
60
  log.info({ model: this.model }, 'Loading local embedding model (first load downloads the model)');
61
+
62
+ // In compiled Bun binaries, bare specifier resolution for packages with
63
+ // subdirectory entry points (like onnxruntime-common's dist/esm/index.js)
64
+ // fails. Additionally, CJS/ESM dual-instance issues cause onnxruntime-node's
65
+ // backend registration to be invisible to transformers. To solve both, the
66
+ // build step pre-bundles all JS deps into a single file placed inside
67
+ // onnxruntime-node/dist/ so native .node binary relative paths resolve.
68
+ const execDir = dirname(process.execPath);
69
+ const bundlePath = join(execDir, 'node_modules', 'onnxruntime-node', 'dist', 'transformers-bundle.mjs');
61
70
  let transformers: typeof import('@huggingface/transformers');
62
71
  try {
63
- transformers = await import('@huggingface/transformers');
72
+ transformers = await import(bundlePath);
64
73
  } catch {
65
- // In compiled Bun binaries, bare specifier resolution starts from the
66
- // virtual /$bunfs/root/ filesystem and can't find externalized packages.
67
- // Fall back to resolving from the executable's real disk location where
68
- // node_modules/ is co-located.
74
+ // Fall back to bare specifier for dev mode (running via `bun run`, not compiled)
69
75
  try {
70
- const execDir = dirname(process.execPath);
71
- const modulePath = join(execDir, 'node_modules', '@huggingface', 'transformers');
72
- transformers = await import(modulePath);
76
+ transformers = await import('@huggingface/transformers');
73
77
  } catch (err) {
74
78
  throw new Error(
75
79
  `Local embedding backend unavailable: failed to load @huggingface/transformers (${err instanceof Error ? err.message : String(err)})`,
76
80
  );
77
81
  }
78
82
  }
83
+
79
84
  this.extractor = await transformers.pipeline('feature-extraction', this.model, {
80
85
  dtype: 'fp32',
81
86
  }) as unknown as FeatureExtractionPipeline;
@@ -7,7 +7,7 @@
7
7
  * answer resolves the request and all other deliveries are marked answered.
8
8
  */
9
9
 
10
- import { and, count, desc, eq, inArray, lt } from 'drizzle-orm';
10
+ import { and, count, desc, eq, inArray, isNotNull, lt } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
13
  import { getLogger } from '../util/logger.js';
@@ -25,7 +25,7 @@ const log = getLogger('guardian-action-store');
25
25
 
26
26
  export type GuardianActionRequestStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
27
27
  export type GuardianActionDeliveryStatus = 'pending' | 'sent' | 'failed' | 'answered' | 'expired' | 'cancelled';
28
- export type ExpiredReason = 'call_timeout' | 'sweep_timeout' | 'cancelled';
28
+ export type ExpiredReason = 'call_timeout' | 'sweep_timeout' | 'cancelled' | 'superseded';
29
29
  export type FollowupState = 'none' | 'awaiting_guardian_choice' | 'dispatching' | 'completed' | 'declined' | 'failed';
30
30
  export type FollowupAction = 'call_back' | 'message_back' | 'decline';
31
31
 
@@ -53,6 +53,8 @@ export interface GuardianActionRequest {
53
53
  followupCompletedAt: number | null;
54
54
  toolName: string | null;
55
55
  inputDigest: string | null;
56
+ supersededByRequestId: string | null;
57
+ supersededAt: number | null;
56
58
  createdAt: number;
57
59
  updatedAt: number;
58
60
  }
@@ -101,6 +103,8 @@ function rowToRequest(row: typeof guardianActionRequests.$inferSelect): Guardian
101
103
  followupCompletedAt: row.followupCompletedAt ?? null,
102
104
  toolName: row.toolName ?? null,
103
105
  inputDigest: row.inputDigest ?? null,
106
+ supersededByRequestId: row.supersededByRequestId ?? null,
107
+ supersededAt: row.supersededAt ?? null,
104
108
  createdAt: row.createdAt,
105
109
  updatedAt: row.updatedAt,
106
110
  };
@@ -172,6 +176,8 @@ export function createGuardianActionRequest(params: {
172
176
  followupCompletedAt: null,
173
177
  toolName: params.toolName ?? null,
174
178
  inputDigest: params.inputDigest ?? null,
179
+ supersededByRequestId: null,
180
+ supersededAt: null,
175
181
  createdAt: now,
176
182
  updatedAt: now,
177
183
  };
@@ -240,6 +246,45 @@ export function countPendingRequestsByCallSessionId(callSessionId: string): numb
240
246
  return row?.count ?? 0;
241
247
  }
242
248
 
249
+ /**
250
+ * Look up the vellum conversation ID used for the first guardian question
251
+ * delivery in a given call session. Returns the conversation ID when one
252
+ * exists, or null if no vellum delivery has been recorded yet.
253
+ *
254
+ * Used by guardian-dispatch to enforce deterministic thread affinity:
255
+ * all guardian questions within the same call session should route to
256
+ * the same vellum conversation.
257
+ */
258
+ export function getGuardianConversationIdForCallSession(callSessionId: string): string | null {
259
+ try {
260
+ const db = getDb();
261
+ const row = db
262
+ .select({ conversationId: guardianActionDeliveries.destinationConversationId })
263
+ .from(guardianActionDeliveries)
264
+ .innerJoin(
265
+ guardianActionRequests,
266
+ eq(guardianActionDeliveries.requestId, guardianActionRequests.id),
267
+ )
268
+ .where(
269
+ and(
270
+ eq(guardianActionRequests.callSessionId, callSessionId),
271
+ eq(guardianActionDeliveries.destinationChannel, 'vellum'),
272
+ isNotNull(guardianActionDeliveries.destinationConversationId),
273
+ ),
274
+ )
275
+ .orderBy(guardianActionDeliveries.createdAt)
276
+ .limit(1)
277
+ .get();
278
+ return row?.conversationId ?? null;
279
+ } catch (err) {
280
+ if (err instanceof Error && err.message.includes('no such table')) {
281
+ log.warn({ err }, 'guardian tables not yet created');
282
+ return null;
283
+ }
284
+ throw err;
285
+ }
286
+ }
287
+
243
288
  /**
244
289
  * First-response-wins resolution. Checks that the request is still
245
290
  * 'pending' before updating; returns the updated request on success
@@ -313,6 +358,84 @@ export function expireGuardianActionRequest(id: string, reason?: ExpiredReason):
313
358
  .run();
314
359
  }
315
360
 
361
+ /**
362
+ * Supersede a pending guardian action request: mark it expired with
363
+ * reason='superseded', record the replacement request ID and timestamp,
364
+ * and expire its active deliveries.
365
+ *
366
+ * Returns the updated request on success, or null if the request was
367
+ * not in 'pending' status (first-writer-wins).
368
+ */
369
+ export function supersedeGuardianActionRequest(
370
+ id: string,
371
+ supersededByRequestId: string,
372
+ ): GuardianActionRequest | null {
373
+ const db = getDb();
374
+ const now = Date.now();
375
+
376
+ db.update(guardianActionRequests)
377
+ .set({
378
+ status: 'expired',
379
+ expiredReason: 'superseded',
380
+ supersededByRequestId,
381
+ supersededAt: now,
382
+ updatedAt: now,
383
+ })
384
+ .where(
385
+ and(
386
+ eq(guardianActionRequests.id, id),
387
+ eq(guardianActionRequests.status, 'pending'),
388
+ ),
389
+ )
390
+ .run();
391
+
392
+ if (rawChanges() === 0) return null;
393
+
394
+ // Also expire active deliveries
395
+ db.update(guardianActionDeliveries)
396
+ .set({ status: 'expired', updatedAt: now })
397
+ .where(
398
+ and(
399
+ eq(guardianActionDeliveries.requestId, id),
400
+ inArray(guardianActionDeliveries.status, ['pending', 'sent']),
401
+ ),
402
+ )
403
+ .run();
404
+
405
+ return getGuardianActionRequest(id);
406
+ }
407
+
408
+ /**
409
+ * Backfill supersession metadata on an already-expired request.
410
+ * Used when the superseding request ID is not known at the time the
411
+ * original request is expired (e.g., the new request is created
412
+ * asynchronously via dispatchGuardianQuestion).
413
+ *
414
+ * Only updates requests that are already in 'expired' status with
415
+ * expired_reason='superseded'.
416
+ */
417
+ export function backfillSupersessionMetadata(
418
+ id: string,
419
+ supersededByRequestId: string,
420
+ ): void {
421
+ const db = getDb();
422
+ const now = Date.now();
423
+
424
+ db.update(guardianActionRequests)
425
+ .set({
426
+ supersededByRequestId,
427
+ supersededAt: now,
428
+ updatedAt: now,
429
+ })
430
+ .where(
431
+ and(
432
+ eq(guardianActionRequests.id, id),
433
+ eq(guardianActionRequests.status, 'expired'),
434
+ ),
435
+ )
436
+ .run();
437
+ }
438
+
316
439
  /**
317
440
  * Get all pending guardian action requests that have expired.
318
441
  */
@@ -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;