@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Shared guardian reply router for inbound channel messages.
3
+ *
4
+ * Provides a single entry point (`routeGuardianReply`) for all inbound
5
+ * guardian reply processing across Telegram, SMS, and WhatsApp. Routes
6
+ * through a priority-ordered pipeline:
7
+ *
8
+ * 1. Deterministic callback/ref parsing (button presses with `apr:<requestId>:<action>`)
9
+ * 2. Request code parsing (6-char alphanumeric prefix matching)
10
+ * 3. NL classification via the conversational approval engine
11
+ *
12
+ * All decisions flow through `applyCanonicalGuardianDecision` from M2,
13
+ * which handles identity validation, expiry checks, CAS resolution,
14
+ * kind-specific resolver dispatch, and grant minting.
15
+ *
16
+ * The router is intentionally kept separate from the inbound message handler
17
+ * to allow for incremental migration and independent testability.
18
+ */
19
+
20
+ import {
21
+ applyCanonicalGuardianDecision,
22
+ type CanonicalDecisionResult,
23
+ } from '../approvals/guardian-decision-primitive.js';
24
+ import type { ActorContext, ChannelDeliveryContext } from '../approvals/guardian-request-resolvers.js';
25
+ import {
26
+ type CanonicalGuardianRequest,
27
+ getCanonicalGuardianRequest,
28
+ getCanonicalGuardianRequestByCode,
29
+ listCanonicalGuardianRequests,
30
+ } from '../memory/canonical-guardian-store.js';
31
+ import { getLogger } from '../util/logger.js';
32
+ import { runApprovalConversationTurn } from './approval-conversation-turn.js';
33
+ import type { ApprovalAction } from './channel-approval-types.js';
34
+ import type {
35
+ ApprovalConversationContext,
36
+ ApprovalConversationGenerator,
37
+ } from './http-types.js';
38
+
39
+ const log = getLogger('guardian-reply-router');
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Context for an inbound message that may be a guardian reply. */
46
+ export interface GuardianReplyContext {
47
+ /** The raw message text (trimmed). */
48
+ messageText: string;
49
+ /** Source channel (telegram, sms, whatsapp, etc.). */
50
+ channel: string;
51
+ /** Actor identity context for the sender. */
52
+ actor: ActorContext;
53
+ /** Conversation ID for this message (may be the guardian's conversation). */
54
+ conversationId: string;
55
+ /** Callback data from button presses (e.g. `apr:<requestId>:<action>`). */
56
+ callbackData?: string;
57
+ /** IDs of known pending canonical requests for this guardian. */
58
+ pendingRequestIds?: string[];
59
+ /** Conversation generator for NL classification (injected by daemon). */
60
+ approvalConversationGenerator?: ApprovalConversationGenerator;
61
+ /** Optional channel delivery context for resolver-driven side effects. */
62
+ channelDeliveryContext?: ChannelDeliveryContext;
63
+ }
64
+
65
+ export type GuardianReplyResultType =
66
+ | 'canonical_decision_applied'
67
+ | 'canonical_decision_stale'
68
+ | 'canonical_resolver_failed'
69
+ | 'code_only_clarification'
70
+ | 'disambiguation_needed'
71
+ | 'nl_keep_pending'
72
+ | 'not_consumed';
73
+
74
+ /** Result from the guardian reply router. */
75
+ export interface GuardianReplyResult {
76
+ /** Whether a decision was applied to a canonical request. */
77
+ decisionApplied: boolean;
78
+ /** Reply text to send back to the guardian (if any). */
79
+ replyText?: string;
80
+ /** Whether the message was consumed and should not enter the agent pipeline. */
81
+ consumed: boolean;
82
+ /** The type of outcome for diagnostics. */
83
+ type: GuardianReplyResultType;
84
+ /** The canonical request ID that was targeted (if any). */
85
+ requestId?: string;
86
+ /** Detailed result from the canonical decision primitive (when a decision was attempted). */
87
+ canonicalResult?: CanonicalDecisionResult;
88
+ /**
89
+ * When true, the caller should skip legacy approval interception for this
90
+ * message. Set by the invite handoff bypass so that "open invite flow"
91
+ * reaches the assistant even when other legacy guardian approvals are pending.
92
+ */
93
+ skipApprovalInterception?: boolean;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Callback data parser — format: "apr:<requestId>:<action>"
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const VALID_ACTIONS: ReadonlySet<string> = new Set([
101
+ 'approve_once',
102
+ 'approve_always',
103
+ 'reject',
104
+ ]);
105
+
106
+ interface ParsedCallback {
107
+ requestId: string;
108
+ action: ApprovalAction;
109
+ }
110
+
111
+ function parseCallbackAction(data: string): ParsedCallback | null {
112
+ const parts = data.split(':');
113
+ if (parts.length < 3 || parts[0] !== 'apr') return null;
114
+ const requestId = parts[1];
115
+ const action = parts.slice(2).join(':');
116
+ if (!requestId || !VALID_ACTIONS.has(action)) return null;
117
+ return { requestId, action: action as ApprovalAction };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Request code parser
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * 6-char alphanumeric request code at the start of a message.
126
+ * Returns the matching canonical request and the remaining text after
127
+ * the code prefix.
128
+ *
129
+ * When `scopeConversationId` is provided, the matched request must belong
130
+ * to that conversation — otherwise the code is treated as unmatched so
131
+ * that requests from other sessions are never accidentally consumed.
132
+ */
133
+ interface CodeParseResult {
134
+ request: CanonicalGuardianRequest;
135
+ remainingText: string;
136
+ }
137
+
138
+ function parseRequestCode(text: string, scopeConversationId?: string): CodeParseResult | null {
139
+ // Request codes are 6 hex chars (A-F, 0-9), uppercase
140
+ const upper = text.toUpperCase();
141
+ const match = upper.match(/^([A-F0-9]{6})(?:\s|$)/);
142
+ if (!match) return null;
143
+
144
+ const code = match[1];
145
+ const request = getCanonicalGuardianRequestByCode(code);
146
+ if (!request) return null;
147
+
148
+ // Scope to the current conversation when requested, so a code belonging
149
+ // to a different session/conversation is not consumed here. Requests with
150
+ // null conversationId are global/unscoped and match any conversation.
151
+ if (scopeConversationId && request.conversationId && request.conversationId !== scopeConversationId) {
152
+ log.info(
153
+ { event: 'router_code_conversation_mismatch', code, requestId: request.id, expected: scopeConversationId, actual: request.conversationId },
154
+ 'Request code matched a canonical request from a different conversation — ignoring',
155
+ );
156
+ return null;
157
+ }
158
+
159
+ const remainingText = text.slice(code.length).trim();
160
+ return { request, remainingText };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Helpers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** Find all pending canonical requests for a guardian actor. */
168
+ function findPendingCanonicalRequests(
169
+ actor: ActorContext,
170
+ pendingRequestIds?: string[],
171
+ conversationId?: string,
172
+ ): CanonicalGuardianRequest[] {
173
+ // When explicit IDs are provided, look them up directly
174
+ if (pendingRequestIds) {
175
+ if (pendingRequestIds.length === 0) {
176
+ return [];
177
+ }
178
+ return pendingRequestIds
179
+ .map(getCanonicalGuardianRequest)
180
+ .filter((r): r is CanonicalGuardianRequest => r?.status === 'pending');
181
+ }
182
+
183
+ // Query by guardian identity when available
184
+ if (actor.externalUserId) {
185
+ return listCanonicalGuardianRequests({
186
+ status: 'pending',
187
+ guardianExternalUserId: actor.externalUserId,
188
+ });
189
+ }
190
+
191
+ // For desktop/trusted actors without an externalUserId, query by
192
+ // conversationId so the NL path can discover pending requests.
193
+ if (conversationId) {
194
+ return listCanonicalGuardianRequests({
195
+ status: 'pending',
196
+ conversationId,
197
+ });
198
+ }
199
+
200
+ // Trusted actors without a conversationId: return all pending requests
201
+ // so desktop sessions can always discover pending guardian work.
202
+ if (actor.isTrusted) {
203
+ return listCanonicalGuardianRequests({ status: 'pending' });
204
+ }
205
+
206
+ return [];
207
+ }
208
+
209
+ /** Map an approval action string to the NL engine's allowed actions for guardians. */
210
+ function guardianAllowedActions(): ApprovalAction[] {
211
+ return ['approve_once', 'reject'];
212
+ }
213
+
214
+ function notConsumed(): GuardianReplyResult {
215
+ return { decisionApplied: false, consumed: false, type: 'not_consumed' };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Core router
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Route an inbound guardian reply through the canonical decision pipeline.
224
+ *
225
+ * This is the single entry point for all inbound guardian reply processing.
226
+ * It handles messages from any channel (Telegram, SMS, WhatsApp) and
227
+ * routes through priority-ordered matching:
228
+ *
229
+ * 1. Deterministic callback parsing (button presses)
230
+ * 2. Request code parsing (6-char alphanumeric prefix)
231
+ * 3. NL classification via the conversational approval engine
232
+ *
233
+ * All decisions flow through `applyCanonicalGuardianDecision`.
234
+ */
235
+ export async function routeGuardianReply(
236
+ ctx: GuardianReplyContext,
237
+ ): Promise<GuardianReplyResult> {
238
+ const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
239
+ const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
240
+
241
+ // ── 1. Deterministic callback parsing (button presses) ──
242
+ // No conversationId scoping here — the guardian's reply comes from a
243
+ // different conversation than the requester's. Identity validation in
244
+ // applyCanonicalGuardianDecision is sufficient to prevent unauthorized
245
+ // cross-user decisions.
246
+ if (callbackData) {
247
+ const parsed = parseCallbackAction(callbackData);
248
+ if (parsed) {
249
+ return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext);
250
+ }
251
+ }
252
+
253
+ // ── 2. Request code parsing (6-char alphanumeric prefix) ──
254
+ // No conversationId scoping — same rationale as the callback path above.
255
+ // The guardian's conversation differs from the requester's.
256
+ if (messageText.length > 0) {
257
+ const codeResult = parseRequestCode(messageText);
258
+ if (codeResult) {
259
+ const { request } = codeResult;
260
+
261
+ if (request.status !== 'pending') {
262
+ log.info(
263
+ { event: 'router_code_already_resolved', requestId: request.id, status: request.status },
264
+ 'Request code matched a non-pending canonical request',
265
+ );
266
+ return {
267
+ decisionApplied: false,
268
+ consumed: true,
269
+ type: 'canonical_decision_stale',
270
+ requestId: request.id,
271
+ replyText: failureReplyText('already_resolved', request.requestCode),
272
+ };
273
+ }
274
+
275
+ // Code-only messages (no decision text after the code) are treated as
276
+ // clarification inquiries — the guardian may be asking "what is this?"
277
+ // rather than intending to approve. Return helpful context instead of
278
+ // silently defaulting to approve_once.
279
+ if (!codeResult.remainingText || codeResult.remainingText.trim().length === 0) {
280
+ // Identity check: only expose request details to the assigned guardian
281
+ // or trusted (desktop) actors. Mirrors the identity check in
282
+ // applyCanonicalGuardianDecision to prevent leaking request details
283
+ // (toolName, questionText) to unauthorized senders.
284
+ if (
285
+ request.guardianExternalUserId &&
286
+ !actor.isTrusted &&
287
+ actor.externalUserId !== request.guardianExternalUserId
288
+ ) {
289
+ log.warn(
290
+ {
291
+ event: 'router_code_only_identity_mismatch',
292
+ requestId: request.id,
293
+ expectedGuardian: request.guardianExternalUserId,
294
+ actualActor: actor.externalUserId,
295
+ },
296
+ 'Code-only clarification blocked: actor identity does not match expected guardian',
297
+ );
298
+ return {
299
+ decisionApplied: false,
300
+ consumed: true,
301
+ type: 'code_only_clarification',
302
+ requestId: request.id,
303
+ replyText: 'Request not found.',
304
+ };
305
+ }
306
+
307
+ log.info(
308
+ { event: 'router_code_only_clarification', requestId: request.id, code: request.requestCode },
309
+ 'Code-only message treated as clarification inquiry',
310
+ );
311
+ return {
312
+ decisionApplied: false,
313
+ consumed: true,
314
+ type: 'code_only_clarification',
315
+ requestId: request.id,
316
+ replyText: composeCodeOnlyClarification(request),
317
+ };
318
+ }
319
+
320
+ // Remaining text present — infer the decision action from it.
321
+ // If the text indicates rejection, use reject; otherwise approve_once.
322
+ const action = inferActionFromText(codeResult.remainingText);
323
+
324
+ return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext);
325
+ }
326
+ }
327
+
328
+ // ── 2.5. Invite handoff bypass for access requests ──
329
+ // When the guardian sends "open invite flow" and there is at least one
330
+ // pending access_request, return not_consumed so the message falls through
331
+ // to the normal assistant turn and can invoke the Trusted Contacts skill.
332
+ if (messageText.length > 0 && pendingRequests.length > 0) {
333
+ const normalized = messageText.trim().toLowerCase().replace(/[.!?]+$/g, '');
334
+ if (normalized === 'open invite flow') {
335
+ const hasAccessRequest = pendingRequests.some(r => r.kind === 'access_request');
336
+ if (hasAccessRequest) {
337
+ log.info(
338
+ { event: 'router_invite_handoff', pendingCount: pendingRequests.length },
339
+ 'Guardian sent "open invite flow" with pending access_request — passing through to assistant',
340
+ );
341
+ return {
342
+ consumed: false,
343
+ decisionApplied: false,
344
+ type: 'not_consumed' as const,
345
+ skipApprovalInterception: true,
346
+ };
347
+ }
348
+ }
349
+ }
350
+
351
+ // ── 2.6. Deterministic plain-text decisions for known pending targets ──
352
+ // Desktop sessions intentionally do not enable NL classification; when the
353
+ // caller has exactly one known pending request and sends an explicit
354
+ // approve/reject phrase ("approve", "yes", "reject", "no"), apply the
355
+ // decision directly instead of falling through to legacy handlers.
356
+ if (messageText.length > 0 && pendingRequests.length > 0) {
357
+ const inferredAction = inferDecisionActionFromFreeText(messageText);
358
+ if (inferredAction) {
359
+ if (pendingRequests.length === 1) {
360
+ return applyDecision(
361
+ pendingRequests[0].id,
362
+ inferredAction,
363
+ actor,
364
+ messageText,
365
+ channelDeliveryContext,
366
+ );
367
+ }
368
+
369
+ const disambiguationReply = composeDisambiguationReply(pendingRequests);
370
+ return {
371
+ decisionApplied: false,
372
+ consumed: true,
373
+ type: 'disambiguation_needed',
374
+ replyText: disambiguationReply,
375
+ };
376
+ }
377
+ }
378
+
379
+ // ── 3. NL classification via the conversational approval engine ──
380
+ if (messageText.length > 0 && approvalConversationGenerator) {
381
+ if (pendingRequests.length === 0) {
382
+ return notConsumed();
383
+ }
384
+
385
+ // Use all pending requests for the guardian without conversation scoping.
386
+ // Guardian requests for channel/voice flows are created on the requester's
387
+ // conversation, not the guardian's reply thread, so filtering by
388
+ // conversationId would incorrectly drop valid pending requests. Identity-
389
+ // based filtering in findPendingCanonicalRequests already constrains
390
+ // results to the correct guardian.
391
+ const pendingRequestsForClassification = pendingRequests;
392
+
393
+ // Build the conversation context for the NL engine
394
+ const engineContext: ApprovalConversationContext = {
395
+ toolName: pendingRequestsForClassification[0].toolName ?? 'unknown',
396
+ allowedActions: guardianAllowedActions(),
397
+ role: 'guardian',
398
+ pendingApprovals: pendingRequestsForClassification.map(r => ({
399
+ requestId: r.id,
400
+ toolName: r.toolName ?? 'unknown',
401
+ })),
402
+ userMessage: messageText,
403
+ };
404
+
405
+ const engineResult = await runApprovalConversationTurn(
406
+ engineContext,
407
+ approvalConversationGenerator,
408
+ );
409
+
410
+ if (engineResult.disposition === 'keep_pending') {
411
+ // When the engine returns keep_pending with multiple pending requests,
412
+ // this likely means the NL classification understood a decision intent
413
+ // but runApprovalConversationTurn fail-closed because no targetRequestId
414
+ // was provided. In this case, produce a disambiguation reply instead of
415
+ // a generic "I couldn't process that" message.
416
+ if (pendingRequestsForClassification.length > 1) {
417
+ log.info(
418
+ { event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
419
+ 'Engine returned keep_pending with multiple pending requests — producing disambiguation',
420
+ );
421
+ const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, undefined);
422
+ return {
423
+ decisionApplied: false,
424
+ consumed: true,
425
+ type: 'disambiguation_needed',
426
+ replyText: disambiguationReply,
427
+ };
428
+ }
429
+ return {
430
+ decisionApplied: false,
431
+ replyText: engineResult.replyText,
432
+ consumed: true,
433
+ type: 'nl_keep_pending',
434
+ };
435
+ }
436
+
437
+ // Decision-bearing disposition from the engine
438
+ let decisionAction = engineResult.disposition as ApprovalAction;
439
+
440
+ // Guardians cannot approve_always — the canonical primitive enforces
441
+ // this too, but enforce it here for clarity.
442
+ if (decisionAction === 'approve_always') {
443
+ decisionAction = 'approve_once';
444
+ }
445
+
446
+ // Resolve the target request
447
+ const targetId = engineResult.targetRequestId
448
+ ?? (pendingRequestsForClassification.length === 1 ? pendingRequestsForClassification[0].id : undefined);
449
+
450
+ if (!targetId) {
451
+ // Multi-pending and engine didn't pick a target — need disambiguation.
452
+ // Fail-closed: never auto-resolve when the target is ambiguous.
453
+ log.info(
454
+ { event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
455
+ 'NL engine returned a decision but no target for multi-pending requests',
456
+ );
457
+ const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, engineResult.replyText);
458
+ return {
459
+ decisionApplied: false,
460
+ consumed: true,
461
+ type: 'disambiguation_needed',
462
+ replyText: disambiguationReply,
463
+ };
464
+ }
465
+
466
+ const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext);
467
+
468
+ // Attach the engine's reply text for stale/expired/identity-mismatch cases,
469
+ // but preserve the explicit failure text when the resolver failed — the engine
470
+ // reply is typically an affirmative confirmation that would be misleading.
471
+ if (engineResult.replyText && result.type !== 'canonical_resolver_failed') {
472
+ result.replyText = engineResult.replyText;
473
+ }
474
+
475
+ return result;
476
+ }
477
+
478
+ // No matching strategy and no engine — not consumed
479
+ return notConsumed();
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Decision application
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /**
487
+ * Apply a decision to a canonical request through the unified primitive.
488
+ */
489
+ async function applyDecision(
490
+ requestId: string,
491
+ action: ApprovalAction,
492
+ actor: ActorContext,
493
+ userText?: string,
494
+ channelDeliveryContext?: ChannelDeliveryContext,
495
+ ): Promise<GuardianReplyResult> {
496
+ const canonicalResult = await applyCanonicalGuardianDecision({
497
+ requestId,
498
+ action,
499
+ actorContext: actor,
500
+ userText,
501
+ channelDeliveryContext,
502
+ });
503
+
504
+ if (canonicalResult.applied) {
505
+ if (canonicalResult.resolverFailed) {
506
+ log.warn(
507
+ {
508
+ event: 'router_resolver_failed',
509
+ requestId,
510
+ action,
511
+ reason: canonicalResult.resolverFailureReason,
512
+ },
513
+ 'Guardian reply router: resolver failed to execute side effects',
514
+ );
515
+
516
+ return {
517
+ decisionApplied: false,
518
+ consumed: true,
519
+ type: 'canonical_resolver_failed',
520
+ replyText: `Decision recorded but could not be completed: ${canonicalResult.resolverFailureReason ?? 'unknown error'}. Please try again.`,
521
+ requestId,
522
+ canonicalResult,
523
+ };
524
+ }
525
+
526
+ log.info(
527
+ {
528
+ event: 'router_decision_applied',
529
+ requestId,
530
+ action,
531
+ grantMinted: canonicalResult.grantMinted,
532
+ },
533
+ 'Guardian reply router applied canonical decision',
534
+ );
535
+
536
+ return {
537
+ decisionApplied: true,
538
+ consumed: true,
539
+ type: 'canonical_decision_applied',
540
+ requestId,
541
+ canonicalResult,
542
+ };
543
+ }
544
+
545
+ log.info(
546
+ {
547
+ event: 'router_decision_not_applied',
548
+ requestId,
549
+ action,
550
+ reason: canonicalResult.reason,
551
+ },
552
+ `Guardian reply router: canonical decision not applied (${canonicalResult.reason})`,
553
+ );
554
+
555
+ // When the canonical request doesn't exist, allow the message to fall
556
+ // through so the legacy handleApprovalInterception handler can process it.
557
+ if (canonicalResult.reason === 'not_found') {
558
+ return notConsumed();
559
+ }
560
+
561
+ return {
562
+ decisionApplied: false,
563
+ consumed: true,
564
+ type: 'canonical_decision_stale',
565
+ requestId,
566
+ canonicalResult,
567
+ replyText: failureReplyText(canonicalResult.reason),
568
+ };
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // Text-to-action inference
573
+ // ---------------------------------------------------------------------------
574
+
575
+ const CODE_REJECT_PATTERNS = /^(no|deny|reject|decline|cancel|block)\b/i;
576
+ const EXPLICIT_APPROVE_PHRASES: ReadonlySet<string> = new Set([
577
+ 'approve',
578
+ 'approved',
579
+ 'approve once',
580
+ 'yes',
581
+ 'y',
582
+ 'allow',
583
+ 'go for it',
584
+ 'go ahead',
585
+ 'proceed',
586
+ 'do it',
587
+ ]);
588
+ const EXPLICIT_REJECT_PHRASES: ReadonlySet<string> = new Set([
589
+ 'reject',
590
+ 'deny',
591
+ 'decline',
592
+ 'no',
593
+ 'n',
594
+ 'block',
595
+ 'cancel',
596
+ ]);
597
+
598
+ function normalizeDecisionPhrase(text: string): string {
599
+ return text
600
+ .trim()
601
+ .toLowerCase()
602
+ .replace(/[.!?]+$/g, '')
603
+ .replace(/\s+/g, ' ');
604
+ }
605
+
606
+ /**
607
+ * Strict free-text decision parser used when no request code is present.
608
+ * Returns null unless the message starts with an explicit approve/reject cue.
609
+ */
610
+ function inferDecisionActionFromFreeText(text: string): ApprovalAction | null {
611
+ const normalized = normalizeDecisionPhrase(text);
612
+ if (!normalized) return null;
613
+ if (EXPLICIT_REJECT_PHRASES.has(normalized)) return 'reject';
614
+ if (EXPLICIT_APPROVE_PHRASES.has(normalized)) return 'approve_once';
615
+ return null;
616
+ }
617
+
618
+ /**
619
+ * Infer a guardian decision action from free-text after a request code.
620
+ * Defaults to approve_once unless clear rejection language is detected.
621
+ */
622
+ function inferActionFromText(text: string): ApprovalAction {
623
+ if (!text || text.trim().length === 0) {
624
+ return 'approve_once';
625
+ }
626
+
627
+ if (CODE_REJECT_PATTERNS.test(text.trim())) {
628
+ return 'reject';
629
+ }
630
+
631
+ return 'approve_once';
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Failure reason reply text
636
+ // ---------------------------------------------------------------------------
637
+
638
+ type CanonicalFailureReason = 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired';
639
+
640
+ /**
641
+ * Map a canonical decision failure reason to a distinct, actionable reply
642
+ * so the guardian understands exactly what happened and what to do next.
643
+ */
644
+ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string | null): string {
645
+ switch (reason) {
646
+ case 'already_resolved':
647
+ return 'This request has already been resolved.';
648
+ case 'expired':
649
+ return 'This request has expired.';
650
+ case 'identity_mismatch':
651
+ return "You don't have permission to decide on this request.";
652
+ case 'invalid_action':
653
+ return requestCode
654
+ ? `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`
655
+ : "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").";
656
+ default:
657
+ return "I couldn't process that request. Please try again.";
658
+ }
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // Code-only clarification
663
+ // ---------------------------------------------------------------------------
664
+
665
+ /**
666
+ * Compose a clarification response when a guardian sends only a request
667
+ * code without any decision text. Provides context about the request and
668
+ * tells the guardian how to approve or reject it.
669
+ */
670
+ function composeCodeOnlyClarification(request: CanonicalGuardianRequest): string {
671
+ const code = request.requestCode ?? 'unknown';
672
+ const toolLabel = request.toolName ?? 'an action';
673
+ const lines: string[] = [
674
+ `I found request ${code} for ${toolLabel}.`,
675
+ ];
676
+ if (request.questionText) {
677
+ lines.push(`Details: ${request.questionText}`);
678
+ }
679
+ lines.push(`Reply "${code} approve" to approve or "${code} reject" to reject.`);
680
+ return lines.join('\n');
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // Disambiguation reply
685
+ // ---------------------------------------------------------------------------
686
+
687
+ /**
688
+ * Compose a disambiguation reply that includes concrete decision examples
689
+ * using actual request codes from the pending requests. Always includes
690
+ * explicit instructions so the guardian knows exactly how to proceed.
691
+ */
692
+ function composeDisambiguationReply(
693
+ pendingRequests: CanonicalGuardianRequest[],
694
+ engineReplyText?: string,
695
+ ): string {
696
+ const lines: string[] = [];
697
+
698
+ if (engineReplyText) {
699
+ lines.push(engineReplyText);
700
+ lines.push('');
701
+ }
702
+
703
+ lines.push(`You have ${pendingRequests.length} pending requests. Please specify which one:`);
704
+
705
+ for (const req of pendingRequests) {
706
+ const toolLabel = req.toolName ?? 'action';
707
+ const code = req.requestCode ?? req.id.slice(0, 6).toUpperCase();
708
+ lines.push(` - ${code}: ${toolLabel}`);
709
+ }
710
+
711
+ // Include a concrete example using the first request's code
712
+ const exampleCode = pendingRequests[0].requestCode ?? pendingRequests[0].id.slice(0, 6).toUpperCase();
713
+ lines.push('');
714
+ lines.push(`Reply "${exampleCode} approve" to approve a specific request.`);
715
+
716
+ return lines.join('\n');
717
+ }