@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -152,20 +152,25 @@ export async function ensureTlsCert(): Promise<{ cert: string; key: string; fing
152
152
  }
153
153
 
154
154
  if (status === 'approaching_expiry') {
155
- // Buffer existing cert/key/fingerprint before attempting renewal.
156
- // generateNewCert() overwrites key.pem in-place, so if it fails mid-flight
157
- // (e.g., key written but cert generation fails), reading from disk in the
158
- // catch block would return a mismatched key/cert pair.
159
- const [existingCert, existingKey, existingFp] = await Promise.all([
160
- readFile(certPath, 'utf-8'),
161
- readFile(keyPath, 'utf-8'),
162
- readFile(fpPath, 'utf-8'),
163
- ]);
164
155
  try {
165
- return await generateNewCert(tlsDir, certPath, keyPath, fpPath);
156
+ // Buffer existing cert/key/fingerprint before attempting renewal.
157
+ // generateNewCert() overwrites key.pem in-place, so if it fails mid-flight
158
+ // (e.g., key written but cert generation fails), reading from disk in the
159
+ // catch block would return a mismatched key/cert pair.
160
+ const [existingCert, existingKey, existingFp] = await Promise.all([
161
+ readFile(certPath, 'utf-8'),
162
+ readFile(keyPath, 'utf-8'),
163
+ readFile(fpPath, 'utf-8'),
164
+ ]);
165
+ try {
166
+ return await generateNewCert(tlsDir, certPath, keyPath, fpPath);
167
+ } catch (err) {
168
+ log.warn({ err }, 'Proactive TLS renewal failed, continuing with existing certificate');
169
+ return { cert: existingCert, key: existingKey, fingerprint: existingFp.trim() };
170
+ }
166
171
  } catch (err) {
167
- log.warn({ err }, 'Proactive TLS renewal failed, continuing with existing certificate');
168
- return { cert: existingCert, key: existingKey, fingerprint: existingFp.trim() };
172
+ log.warn({ err }, 'Failed to read existing TLS cert for buffering, attempting regeneration');
173
+ return await generateNewCert(tlsDir, certPath, keyPath, fpPath);
169
174
  }
170
175
  }
171
176
 
@@ -7,11 +7,11 @@
7
7
  * registry entry instead of another if/else branch.
8
8
  */
9
9
 
10
- import { normalizeActivationKey } from './handlers/config-voice.js';
11
10
  import { updatePublishedAppDeployment } from '../services/published-app-updater.js';
12
11
  import { openAppViaSurface } from '../tools/apps/open-proxy.js';
13
12
  import type { ToolExecutionResult } from '../tools/types.js';
14
13
  import { isDoordashCommand, updateDoordashProgress } from './doordash-steps.js';
14
+ import { normalizeActivationKey } from './handlers/config-voice.js';
15
15
  import type { ServerMessage } from './ipc-protocol.js';
16
16
  import {
17
17
  refreshSurfacesForApp,
@@ -8,33 +8,31 @@
8
8
  * - delivery-channels.ts — verification replies, segment progress, delivery guards
9
9
  */
10
10
 
11
+ export type { PendingVerificationReply } from './delivery-channels.js';
12
+ export {
13
+ claimRunDelivery,
14
+ clearPendingVerificationReply,
15
+ getDeliveredSegmentCount,
16
+ getPendingVerificationReply,
17
+ resetAllRunDeliveryClaims,
18
+ resetRunDeliveryClaim,
19
+ storePendingVerificationReply,
20
+ updateDeliveredSegmentCount,
21
+ } from './delivery-channels.js';
22
+ export type { InboundResult, RecordInboundOptions } from './delivery-crud.js';
11
23
  export {
12
- recordInbound,
13
- linkMessage,
14
- findMessageBySourceId,
15
- storePayload,
16
24
  clearPayload,
25
+ findMessageBySourceId,
17
26
  getLatestStoredPayload,
27
+ linkMessage,
28
+ recordInbound,
29
+ storePayload,
18
30
  } from './delivery-crud.js';
19
- export type { InboundResult, RecordInboundOptions } from './delivery-crud.js';
20
-
21
31
  export {
22
32
  acknowledgeDelivery,
33
+ getDeadLetterEvents,
34
+ getRetryableEvents,
23
35
  markProcessed,
24
36
  recordProcessingFailure,
25
- getRetryableEvents,
26
- getDeadLetterEvents,
27
37
  replayDeadLetters,
28
38
  } from './delivery-status.js';
29
-
30
- export {
31
- storePendingVerificationReply,
32
- getPendingVerificationReply,
33
- clearPendingVerificationReply,
34
- getDeliveredSegmentCount,
35
- updateDeliveredSegmentCount,
36
- claimRunDelivery,
37
- resetRunDeliveryClaim,
38
- resetAllRunDeliveryClaims,
39
- } from './delivery-channels.js';
40
- export type { PendingVerificationReply } from './delivery-channels.js';
@@ -10,60 +10,57 @@
10
10
  * This file re-exports everything for backward compatibility.
11
11
  */
12
12
 
13
+ export {
14
+ type ApprovalRequestStatus,
15
+ countPendingByConversation,
16
+ createApprovalRequest,
17
+ findPendingAccessRequestForRequester,
18
+ getAllPendingApprovalsByGuardianChat,
19
+ getApprovalRequestById,
20
+ getApprovalRequestByRunId,
21
+ getExpiredPendingApprovals,
22
+ getPendingApprovalByGuardianChat,
23
+ getPendingApprovalByRequestAndGuardianChat,
24
+ getPendingApprovalByRunAndGuardianChat,
25
+ getPendingApprovalForRequest,
26
+ getPendingApprovalForRun,
27
+ getUnresolvedApprovalForRequest,
28
+ getUnresolvedApprovalForRun,
29
+ type GuardianApprovalRequest,
30
+ listPendingApprovalRequests,
31
+ resolveApprovalRequest,
32
+ updateApprovalDecision,
33
+ } from './guardian-approvals.js';
13
34
  export {
14
35
  type BindingStatus,
15
- type GuardianBinding,
16
36
  createBinding,
17
37
  getActiveBinding,
38
+ type GuardianBinding,
18
39
  revokeBinding,
19
40
  } from './guardian-bindings.js';
20
-
21
41
  export {
42
+ getRateLimit,
43
+ recordInvalidAttempt,
44
+ resetRateLimit,
45
+ type VerificationRateLimit,
46
+ } from './guardian-rate-limits.js';
47
+ export {
48
+ bindSessionIdentity,
22
49
  type ChallengeStatus,
23
- type SessionStatus,
24
- type IdentityBindingStatus,
25
- type VerificationPurpose,
26
- type VerificationChallenge,
27
- createChallenge,
28
- revokePendingChallenges,
29
- findPendingChallengeByHash,
30
- findPendingChallengeForChannel,
31
50
  consumeChallenge,
51
+ countRecentSendsToDestination,
52
+ createChallenge,
32
53
  createVerificationSession,
33
54
  findActiveSession,
55
+ findPendingChallengeByHash,
56
+ findPendingChallengeForChannel,
34
57
  findSessionByBootstrapTokenHash,
35
58
  findSessionByIdentity,
36
- updateSessionStatus,
59
+ type IdentityBindingStatus,
60
+ revokePendingChallenges,
61
+ type SessionStatus,
37
62
  updateSessionDelivery,
38
- countRecentSendsToDestination,
39
- bindSessionIdentity,
63
+ updateSessionStatus,
64
+ type VerificationChallenge,
65
+ type VerificationPurpose,
40
66
  } from './guardian-verification.js';
41
-
42
- export {
43
- type ApprovalRequestStatus,
44
- type GuardianApprovalRequest,
45
- createApprovalRequest,
46
- getPendingApprovalForRun,
47
- getPendingApprovalForRequest,
48
- getUnresolvedApprovalForRun,
49
- getUnresolvedApprovalForRequest,
50
- getPendingApprovalByGuardianChat,
51
- getPendingApprovalByRunAndGuardianChat,
52
- getPendingApprovalByRequestAndGuardianChat,
53
- getAllPendingApprovalsByGuardianChat,
54
- getExpiredPendingApprovals,
55
- updateApprovalDecision,
56
- listPendingApprovalRequests,
57
- getApprovalRequestById,
58
- getApprovalRequestByRunId,
59
- resolveApprovalRequest,
60
- countPendingByConversation,
61
- findPendingAccessRequestForRequester,
62
- } from './guardian-approvals.js';
63
-
64
- export {
65
- type VerificationRateLimit,
66
- getRateLimit,
67
- recordInvalidAttempt,
68
- resetRateLimit,
69
- } from './guardian-rate-limits.js';
@@ -1,4 +1,4 @@
1
- import { and, count, eq, inArray, isNull, sql, asc } from 'drizzle-orm';
1
+ import { and, asc,count, eq, inArray, isNull, sql } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
  import { z } from 'zod';
4
4
 
@@ -11,7 +11,7 @@ import { getLogger } from '../util/logger.js';
11
11
  import { createRowMapper } from '../util/row-mapper.js';
12
12
  import { deleteOrphanAttachments } from './attachments-store.js';
13
13
  import { projectAssistantMessage } from './conversation-attention-store.js';
14
- import { getDb, rawAll, rawExec, rawGet } from './db.js';
14
+ import { getDb, rawExec, rawGet } from './db.js';
15
15
  import { indexMessageNow } from './indexer.js';
16
16
  import { channelInboundEvents, conversations, llmRequestLogs, memoryEmbeddings, memoryItemEntities, memoryItems, memoryItemSources, memorySegments, messageAttachments, messages, toolInvocations } from './schema.js';
17
17
 
@@ -1,9 +1,9 @@
1
1
  import { and, asc, count, desc, eq, gte, lt, ne, or, sql } from 'drizzle-orm';
2
2
 
3
3
  import { getLogger } from '../util/logger.js';
4
- import { getDb, rawAll } from './db.js';
5
- import { parseConversation, parseMessage } from './conversation-crud.js';
6
4
  import type { ConversationRow, MessageRow } from './conversation-crud.js';
5
+ import { parseConversation, parseMessage } from './conversation-crud.js';
6
+ import { getDb, rawAll } from './db.js';
7
7
  import { conversations, messages } from './schema.js';
8
8
  import { buildFtsMatchQuery } from './search/lexical.js';
9
9
 
@@ -2,44 +2,43 @@
2
2
  // Existing imports from this file continue to work without changes.
3
3
 
4
4
  export {
5
- messageMetadataSchema,
6
- type MessageMetadata,
7
- provenanceFromGuardianContext,
5
+ addMessage,
6
+ clearAll,
8
7
  type ConversationRow,
9
- parseConversation,
10
- type MessageRow,
11
- parseMessage,
12
8
  createConversation,
9
+ deleteConversation,
10
+ type DeletedMemoryIds,
11
+ deleteLastExchange,
12
+ deleteMessageById,
13
13
  getConversation,
14
- getConversationThreadType,
15
14
  getConversationMemoryScopeId,
16
- deleteConversation,
17
- addMessage,
18
- getMessages,
15
+ getConversationOriginChannel,
16
+ getConversationOriginInterface,
17
+ getConversationThreadType,
19
18
  getMessageById,
20
- updateConversationTitle,
21
- updateConversationUsage,
22
- updateConversationContextWindow,
23
- clearAll,
24
- deleteLastExchange,
25
- type DeletedMemoryIds,
26
- updateMessageContent,
19
+ getMessages,
20
+ type MessageMetadata,
21
+ messageMetadataSchema,
22
+ type MessageRow,
23
+ parseConversation,
24
+ parseMessage,
25
+ provenanceFromGuardianContext,
27
26
  relinkAttachments,
28
- deleteMessageById,
29
27
  setConversationOriginChannelIfUnset,
30
- getConversationOriginChannel,
31
28
  setConversationOriginInterfaceIfUnset,
32
- getConversationOriginInterface,
29
+ updateConversationContextWindow,
30
+ updateConversationTitle,
31
+ updateConversationUsage,
32
+ updateMessageContent,
33
33
  } from './conversation-crud.js';
34
-
35
34
  export {
36
- listConversations,
35
+ type ConversationSearchResult,
37
36
  countConversations,
38
37
  getLatestConversation,
39
- getNextMessage,
40
- type PaginatedMessagesResult,
41
38
  getMessagesPaginated,
39
+ getNextMessage,
42
40
  isLastUserMessageToolResult,
43
- type ConversationSearchResult,
41
+ listConversations,
42
+ type PaginatedMessagesResult,
44
43
  searchConversations,
45
44
  } from './conversation-queries.js';
@@ -8,6 +8,7 @@ import {
8
8
  createConversationAttentionTables,
9
9
  createCoreIndexes,
10
10
  createCoreTables,
11
+ createScopedApprovalGrantsTable,
11
12
  createExternalConversationBindingsTables,
12
13
  createFollowupsTables,
13
14
  createMediaAssetsTables,
@@ -17,14 +18,17 @@ import {
17
18
  createTasksAndWorkItemsTables,
18
19
  createWatchersAndLogsTables,
19
20
  migrateCallSessionMode,
20
- migrateFkCascadeRebuilds,
21
21
  migrateChannelInboundDeliveredSegments,
22
22
  migrateConversationsThreadTypeIndex,
23
+ migrateFkCascadeRebuilds,
23
24
  migrateGuardianActionFollowup,
25
+ migrateGuardianActionToolMetadata,
24
26
  migrateGuardianBootstrapToken,
27
+ migrateGuardianDeliveryConversationIndex,
25
28
  migrateGuardianVerificationPurpose,
26
29
  migrateGuardianVerificationSessions,
27
30
  migrateMessagesFtsBackfill,
31
+ migrateNotificationDeliveryThreadDecision,
28
32
  migrateReminderRoutingIntent,
29
33
  migrateSchemaIndexesAndColumns,
30
34
  recoverCrashedMigrations,
@@ -100,9 +104,15 @@ export function initializeDb(): void {
100
104
  // 14c. Guardian action follow-up lifecycle columns (timeout reason, late answers)
101
105
  migrateGuardianActionFollowup(database);
102
106
 
107
+ // 14c2. Guardian action tool-approval metadata columns (tool_name, input_digest)
108
+ migrateGuardianActionToolMetadata(database);
109
+
103
110
  // 14d. Index on conversations.thread_type for frequent WHERE filters
104
111
  migrateConversationsThreadTypeIndex(database);
105
112
 
113
+ // 14e. Index on guardian_action_deliveries.destination_conversation_id for conversation-based lookups
114
+ migrateGuardianDeliveryConversationIndex(database);
115
+
106
116
  // 15. Notification system
107
117
  createNotificationTables(database);
108
118
 
@@ -125,5 +135,11 @@ export function initializeDb(): void {
125
135
  // 21. Rebuild tables to add ON DELETE CASCADE to FK constraints
126
136
  migrateFkCascadeRebuilds(database);
127
137
 
138
+ // 22. Scoped approval grants (channel-agnostic one-time-use grants)
139
+ createScopedApprovalGrantsTable(database);
140
+
141
+ // 23. Thread decision audit columns on notification_deliveries
142
+ migrateNotificationDeliveryThreadDecision(database);
143
+
128
144
  validateMigrationState(database);
129
145
  }
@@ -1,3 +1,5 @@
1
+ import { dirname, join } from 'node:path';
2
+
1
3
  import { getLogger } from '../util/logger.js';
2
4
  import { PromiseGuard } from '../util/promise-guard.js';
3
5
  import type { EmbeddingBackend, EmbeddingRequestOptions } from './embedding-backend.js';
@@ -59,13 +61,20 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
59
61
  let transformers: typeof import('@huggingface/transformers');
60
62
  try {
61
63
  transformers = await import('@huggingface/transformers');
62
- } catch (err) {
63
- // onnxruntime-node is not bundled in compiled binaries, so the import
64
- // fails at runtime. Surface a clear error so callers can fall back to
65
- // another embedding backend.
66
- throw new Error(
67
- `Local embedding backend unavailable: failed to load @huggingface/transformers (${err instanceof Error ? err.message : String(err)})`,
68
- );
64
+ } 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.
69
+ try {
70
+ const execDir = dirname(process.execPath);
71
+ const modulePath = join(execDir, 'node_modules', '@huggingface', 'transformers');
72
+ transformers = await import(modulePath);
73
+ } catch (err) {
74
+ throw new Error(
75
+ `Local embedding backend unavailable: failed to load @huggingface/transformers (${err instanceof Error ? err.message : String(err)})`,
76
+ );
77
+ }
69
78
  }
70
79
  this.extractor = await transformers.pipeline('feature-extraction', this.model, {
71
80
  dtype: 'fp32',
@@ -84,37 +84,52 @@ function reconcileTable(opts: {
84
84
  */
85
85
  export function reconcileFtsIndexes(): FtsReconciliationResult[] {
86
86
  const results: FtsReconciliationResult[] = [];
87
+ const errors: unknown[] = [];
87
88
 
88
89
  // memory_segment_fts tracks memory_segments
89
- const memResult = reconcileTable({
90
- ftsTable: 'memory_segment_fts',
91
- ftsIdColumn: 'segment_id',
92
- ftsContentColumn: 'text',
93
- baseTable: 'memory_segments',
94
- baseIdColumn: 'id',
95
- baseContentColumn: 'text',
96
- });
97
- results.push(memResult);
98
- if (memResult.missingInserted > 0 || memResult.orphansRemoved > 0 || memResult.staleRefreshed > 0) {
99
- log.info(memResult, 'Reconciled memory_segment_fts');
100
- } else {
101
- log.debug(memResult, 'memory_segment_fts is in sync');
90
+ try {
91
+ const memResult = reconcileTable({
92
+ ftsTable: 'memory_segment_fts',
93
+ ftsIdColumn: 'segment_id',
94
+ ftsContentColumn: 'text',
95
+ baseTable: 'memory_segments',
96
+ baseIdColumn: 'id',
97
+ baseContentColumn: 'text',
98
+ });
99
+ results.push(memResult);
100
+ if (memResult.missingInserted > 0 || memResult.orphansRemoved > 0 || memResult.staleRefreshed > 0) {
101
+ log.info(memResult, 'Reconciled memory_segment_fts');
102
+ } else {
103
+ log.debug(memResult, 'memory_segment_fts is in sync');
104
+ }
105
+ } catch (err) {
106
+ log.error({ err }, 'Failed to reconcile memory_segment_fts');
107
+ errors.push(err);
102
108
  }
103
109
 
104
110
  // messages_fts tracks messages
105
- const msgResult = reconcileTable({
106
- ftsTable: 'messages_fts',
107
- ftsIdColumn: 'message_id',
108
- ftsContentColumn: 'content',
109
- baseTable: 'messages',
110
- baseIdColumn: 'id',
111
- baseContentColumn: 'content',
112
- });
113
- results.push(msgResult);
114
- if (msgResult.missingInserted > 0 || msgResult.orphansRemoved > 0 || msgResult.staleRefreshed > 0) {
115
- log.info(msgResult, 'Reconciled messages_fts');
116
- } else {
117
- log.debug(msgResult, 'messages_fts is in sync');
111
+ try {
112
+ const msgResult = reconcileTable({
113
+ ftsTable: 'messages_fts',
114
+ ftsIdColumn: 'message_id',
115
+ ftsContentColumn: 'content',
116
+ baseTable: 'messages',
117
+ baseIdColumn: 'id',
118
+ baseContentColumn: 'content',
119
+ });
120
+ results.push(msgResult);
121
+ if (msgResult.missingInserted > 0 || msgResult.orphansRemoved > 0 || msgResult.staleRefreshed > 0) {
122
+ log.info(msgResult, 'Reconciled messages_fts');
123
+ } else {
124
+ log.debug(msgResult, 'messages_fts is in sync');
125
+ }
126
+ } catch (err) {
127
+ log.error({ err }, 'Failed to reconcile messages_fts');
128
+ errors.push(err);
129
+ }
130
+
131
+ if (errors.length > 0) {
132
+ throw new AggregateError(errors, `FTS reconciliation failed for ${errors.length} table(s)`);
118
133
  }
119
134
 
120
135
  return results;
@@ -7,7 +7,7 @@
7
7
  * answer resolves the request and all other deliveries are marked answered.
8
8
  */
9
9
 
10
- import { and, desc, eq, inArray, lt } from 'drizzle-orm';
10
+ import { and, count, desc, eq, inArray, lt } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
13
  import { getLogger } from '../util/logger.js';
@@ -51,6 +51,8 @@ export interface GuardianActionRequest {
51
51
  lateAnsweredAt: number | null;
52
52
  followupAction: FollowupAction | null;
53
53
  followupCompletedAt: number | null;
54
+ toolName: string | null;
55
+ inputDigest: string | null;
54
56
  createdAt: number;
55
57
  updatedAt: number;
56
58
  }
@@ -97,6 +99,8 @@ function rowToRequest(row: typeof guardianActionRequests.$inferSelect): Guardian
97
99
  lateAnsweredAt: row.lateAnsweredAt ?? null,
98
100
  followupAction: (row.followupAction as FollowupAction) ?? null,
99
101
  followupCompletedAt: row.followupCompletedAt ?? null,
102
+ toolName: row.toolName ?? null,
103
+ inputDigest: row.inputDigest ?? null,
100
104
  createdAt: row.createdAt,
101
105
  updatedAt: row.updatedAt,
102
106
  };
@@ -137,6 +141,8 @@ export function createGuardianActionRequest(params: {
137
141
  pendingQuestionId: string;
138
142
  questionText: string;
139
143
  expiresAt: number;
144
+ toolName?: string;
145
+ inputDigest?: string;
140
146
  }): GuardianActionRequest {
141
147
  const db = getDb();
142
148
  const now = Date.now();
@@ -164,6 +170,8 @@ export function createGuardianActionRequest(params: {
164
170
  lateAnsweredAt: null,
165
171
  followupAction: null,
166
172
  followupCompletedAt: null,
173
+ toolName: params.toolName ?? null,
174
+ inputDigest: params.inputDigest ?? null,
167
175
  createdAt: now,
168
176
  updatedAt: now,
169
177
  };
@@ -212,6 +220,26 @@ export function getPendingRequestByCallSessionId(callSessionId: string): Guardia
212
220
  return row ? rowToRequest(row) : null;
213
221
  }
214
222
 
223
+ /**
224
+ * Count pending guardian action requests for a given call session.
225
+ * Used as a candidate-affinity hint so the decision engine knows how many
226
+ * active guardian requests already exist for the current call.
227
+ */
228
+ export function countPendingRequestsByCallSessionId(callSessionId: string): number {
229
+ const db = getDb();
230
+ const row = db
231
+ .select({ count: count() })
232
+ .from(guardianActionRequests)
233
+ .where(
234
+ and(
235
+ eq(guardianActionRequests.callSessionId, callSessionId),
236
+ eq(guardianActionRequests.status, 'pending'),
237
+ ),
238
+ )
239
+ .get();
240
+ return row?.count ?? 0;
241
+ }
242
+
215
243
  /**
216
244
  * First-response-wins resolution. Checks that the request is still
217
245
  * 'pending' before updating; returns the updated request on success
@@ -595,6 +623,16 @@ export function getPendingDeliveriesByDestination(
595
623
  * Look up a pending delivery by destination conversation ID (for mac channel routing).
596
624
  */
597
625
  export function getPendingDeliveryByConversation(conversationId: string): GuardianActionDelivery | null {
626
+ const all = getPendingDeliveriesByConversation(conversationId);
627
+ return all.length > 0 ? all[0] : null;
628
+ }
629
+
630
+ /**
631
+ * Look up all pending deliveries by destination conversation ID.
632
+ * Used for disambiguation when a reused vellum thread has multiple active
633
+ * guardian requests.
634
+ */
635
+ export function getPendingDeliveriesByConversation(conversationId: string): GuardianActionDelivery[] {
598
636
  try {
599
637
  const db = getDb();
600
638
  const rows = db
@@ -612,11 +650,11 @@ export function getPendingDeliveryByConversation(conversationId: string): Guardi
612
650
  ),
613
651
  )
614
652
  .all();
615
- return rows.length > 0 ? rowToDelivery(rows[0].delivery) : null;
653
+ return rows.map((r) => rowToDelivery(r.delivery));
616
654
  } catch (err) {
617
655
  if (err instanceof Error && err.message.includes('no such table')) {
618
656
  log.warn({ err }, 'guardian tables not yet created');
619
- return null;
657
+ return [];
620
658
  }
621
659
  throw err;
622
660
  }
@@ -669,6 +707,16 @@ export function getExpiredDeliveriesByDestination(
669
707
  * Look up an expired delivery by destination conversation ID (for mac channel routing).
670
708
  */
671
709
  export function getExpiredDeliveryByConversation(conversationId: string): GuardianActionDelivery | null {
710
+ const all = getExpiredDeliveriesByConversation(conversationId);
711
+ return all.length > 0 ? all[0] : null;
712
+ }
713
+
714
+ /**
715
+ * Look up all expired deliveries by destination conversation ID.
716
+ * Used for disambiguation when a reused vellum thread has multiple expired
717
+ * guardian requests eligible for follow-up.
718
+ */
719
+ export function getExpiredDeliveriesByConversation(conversationId: string): GuardianActionDelivery[] {
672
720
  try {
673
721
  const db = getDb();
674
722
  const rows = db
@@ -687,11 +735,11 @@ export function getExpiredDeliveryByConversation(conversationId: string): Guardi
687
735
  ),
688
736
  )
689
737
  .all();
690
- return rows.length > 0 ? rowToDelivery(rows[0].delivery) : null;
738
+ return rows.map((r) => rowToDelivery(r.delivery));
691
739
  } catch (err) {
692
740
  if (err instanceof Error && err.message.includes('no such table')) {
693
741
  log.warn({ err }, 'guardian tables not yet created');
694
- return null;
742
+ return [];
695
743
  }
696
744
  throw err;
697
745
  }
@@ -746,6 +794,16 @@ export function getFollowupDeliveriesByDestination(
746
794
  * state by destination conversation ID (for mac channel routing).
747
795
  */
748
796
  export function getFollowupDeliveryByConversation(conversationId: string): GuardianActionDelivery | null {
797
+ const all = getFollowupDeliveriesByConversation(conversationId);
798
+ return all.length > 0 ? all[0] : null;
799
+ }
800
+
801
+ /**
802
+ * Look up all deliveries for requests in `awaiting_guardian_choice` follow-up
803
+ * state by destination conversation ID. Used for disambiguation when a reused
804
+ * vellum thread has multiple follow-up guardian requests.
805
+ */
806
+ export function getFollowupDeliveriesByConversation(conversationId: string): GuardianActionDelivery[] {
749
807
  try {
750
808
  const db = getDb();
751
809
  const rows = db
@@ -764,11 +822,11 @@ export function getFollowupDeliveryByConversation(conversationId: string): Guard
764
822
  ),
765
823
  )
766
824
  .all();
767
- return rows.length > 0 ? rowToDelivery(rows[0].delivery) : null;
825
+ return rows.map((r) => rowToDelivery(r.delivery));
768
826
  } catch (err) {
769
827
  if (err instanceof Error && err.message.includes('no such table')) {
770
828
  log.warn({ err }, 'guardian tables not yet created');
771
- return null;
829
+ return [];
772
830
  }
773
831
  throw err;
774
832
  }
@@ -131,6 +131,7 @@ export function createChallenge(params: {
131
131
  nextResendAt: null,
132
132
  codeDigits: 6,
133
133
  maxAttempts: 3,
134
+ verificationPurpose: 'guardian' as const,
134
135
  bootstrapTokenHash: null,
135
136
  createdAt: now,
136
137
  updatedAt: now,
@@ -2,6 +2,7 @@ import { getConfig } from '../config/loader.js';
2
2
  import type { AssistantConfig } from '../config/types.js';
3
3
  import { getLogger } from '../util/logger.js';
4
4
  import { rawRun } from './db.js';
5
+ import { reconcileFtsIndexes } from './fts-reconciler.js';
5
6
  import { backfillEntityRelationsJob,backfillJob } from './job-handlers/backfill.js';
6
7
  import { checkContradictionsJob, cleanupStaleSupersededItemsJob, pruneOldConversationsJob } from './job-handlers/cleanup.js';
7
8
  import { cleanupResolvedConflictsJob,resolvePendingConflictsForMessageJob } from './job-handlers/conflict.js';
@@ -9,7 +10,6 @@ import { cleanupResolvedConflictsJob,resolvePendingConflictsForMessageJob } from
9
10
  import { embedItemJob, embedSegmentJob, embedSummaryJob } from './job-handlers/embedding.js';
10
11
  import { extractEntitiesJob,extractItemsJob } from './job-handlers/extraction.js';
11
12
  import { deleteQdrantVectorsJob,rebuildIndexJob } from './job-handlers/index-maintenance.js';
12
- import { reconcileFtsIndexes } from './fts-reconciler.js';
13
13
  import { mediaProcessingJob } from './job-handlers/media-processing.js';
14
14
  import { buildConversationSummaryJob, buildGlobalSummaryJob } from './job-handlers/summarization.js';
15
15
  import {
@@ -18,7 +18,6 @@ import {
18
18
  RETRY_MAX_ATTEMPTS,
19
19
  retryDelayForAttempt,
20
20
  } from './job-utils.js';
21
- import { QdrantCircuitOpenError } from './qdrant-circuit-breaker.js';
22
21
  import {
23
22
  claimMemoryJobs,
24
23
  completeMemoryJob,
@@ -32,6 +31,7 @@ import {
32
31
  type MemoryJob,
33
32
  resetRunningJobsToPending,
34
33
  } from './jobs-store.js';
34
+ import { QdrantCircuitOpenError } from './qdrant-circuit-breaker.js';
35
35
  import { bumpMemoryVersion } from './recall-cache.js';
36
36
 
37
37
  // Re-export public utilities consumed by tests and other modules