@vellumai/assistant 0.5.7 → 0.5.8

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 (197) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  14. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  15. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  16. package/src/__tests__/config-schema.test.ts +2 -0
  17. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  21. package/src/__tests__/conversation-error.test.ts +15 -1
  22. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  23. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-queue.test.ts +0 -1
  26. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  30. package/src/__tests__/credential-execution-client.test.ts +5 -2
  31. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  32. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  33. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  35. package/src/__tests__/credentials-cli.test.ts +4 -3
  36. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  37. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  38. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  39. package/src/__tests__/journal-context.test.ts +335 -0
  40. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  42. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  43. package/src/__tests__/memory-regressions.test.ts +408 -363
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  45. package/src/__tests__/non-member-access-request.test.ts +2 -2
  46. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  47. package/src/__tests__/oauth-cli.test.ts +5 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  49. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  50. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  51. package/src/__tests__/relay-server.test.ts +1 -2
  52. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  53. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  54. package/src/__tests__/secure-keys.test.ts +18 -15
  55. package/src/__tests__/skill-memory.test.ts +17 -3
  56. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  57. package/src/__tests__/stt-hints.test.ts +437 -0
  58. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  59. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  60. package/src/__tests__/voice-quality.test.ts +58 -0
  61. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  62. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  63. package/src/acp/agent-process.ts +9 -1
  64. package/src/agent/loop.ts +1 -1
  65. package/src/approvals/guardian-request-resolvers.ts +164 -38
  66. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  67. package/src/calls/call-controller.ts +9 -5
  68. package/src/calls/fish-audio-client.ts +26 -14
  69. package/src/calls/stt-hints.ts +189 -0
  70. package/src/calls/tts-text-sanitizer.ts +61 -0
  71. package/src/calls/twilio-routes.ts +32 -4
  72. package/src/calls/voice-quality.ts +15 -3
  73. package/src/calls/voice-session-bridge.ts +1 -0
  74. package/src/cli/commands/avatar.ts +2 -2
  75. package/src/cli/commands/credentials.ts +110 -94
  76. package/src/cli/commands/doctor.ts +2 -2
  77. package/src/cli/commands/keys.ts +7 -7
  78. package/src/cli/commands/memory.ts +1 -1
  79. package/src/cli/commands/oauth/connections.ts +11 -29
  80. package/src/cli/commands/oauth/platform.ts +389 -43
  81. package/src/cli/lib/daemon-credential-client.ts +284 -0
  82. package/src/cli.ts +1 -1
  83. package/src/config/bundled-skills/AGENTS.md +34 -0
  84. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  85. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  86. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  87. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  88. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  89. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  90. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  91. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  92. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  93. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  94. package/src/config/bundled-tool-registry.ts +4 -0
  95. package/src/config/defaults.ts +0 -2
  96. package/src/config/env-registry.ts +4 -4
  97. package/src/config/env.ts +14 -1
  98. package/src/config/feature-flag-registry.json +1 -1
  99. package/src/config/loader.ts +8 -11
  100. package/src/config/schema.ts +5 -16
  101. package/src/config/schemas/calls.ts +17 -0
  102. package/src/config/schemas/inference.ts +2 -2
  103. package/src/config/schemas/journal.ts +16 -0
  104. package/src/config/schemas/memory-processing.ts +2 -2
  105. package/src/config/types.ts +1 -0
  106. package/src/contacts/contact-store.ts +2 -2
  107. package/src/credential-execution/executable-discovery.ts +1 -1
  108. package/src/credential-execution/startup-timeout.ts +36 -0
  109. package/src/daemon/approval-generators.ts +3 -9
  110. package/src/daemon/conversation-error.ts +13 -1
  111. package/src/daemon/conversation-memory.ts +1 -2
  112. package/src/daemon/conversation-process.ts +18 -1
  113. package/src/daemon/conversation-surfaces.ts +30 -1
  114. package/src/daemon/conversation.ts +20 -9
  115. package/src/daemon/guardian-action-generators.ts +3 -9
  116. package/src/daemon/lifecycle.ts +18 -11
  117. package/src/daemon/message-types/conversations.ts +1 -0
  118. package/src/daemon/server.ts +2 -3
  119. package/src/memory/app-store.ts +31 -0
  120. package/src/memory/db-init.ts +4 -0
  121. package/src/memory/indexer.ts +19 -10
  122. package/src/memory/items-extractor.ts +315 -322
  123. package/src/memory/job-handlers/summarization.ts +26 -16
  124. package/src/memory/jobs-store.ts +33 -1
  125. package/src/memory/journal-memory.ts +214 -0
  126. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  127. package/src/memory/migrations/index.ts +1 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/retriever.test.ts +37 -25
  130. package/src/memory/retriever.ts +24 -49
  131. package/src/memory/schema/memory-core.ts +2 -0
  132. package/src/memory/search/formatting.ts +7 -44
  133. package/src/memory/search/staleness.ts +4 -0
  134. package/src/memory/search/tier-classifier.ts +10 -2
  135. package/src/memory/search/types.ts +2 -5
  136. package/src/memory/task-memory-cleanup.ts +4 -3
  137. package/src/notifications/adapters/slack.ts +168 -6
  138. package/src/notifications/broadcaster.ts +1 -0
  139. package/src/notifications/copy-composer.ts +59 -2
  140. package/src/notifications/signal.ts +2 -0
  141. package/src/notifications/types.ts +2 -0
  142. package/src/prompts/journal-context.ts +133 -0
  143. package/src/prompts/persona-resolver.ts +80 -24
  144. package/src/prompts/system-prompt.ts +8 -0
  145. package/src/prompts/templates/SOUL.md +10 -0
  146. package/src/providers/provider-send-message.ts +3 -32
  147. package/src/providers/registry.ts +2 -139
  148. package/src/providers/types.ts +1 -1
  149. package/src/runtime/access-request-helper.ts +4 -0
  150. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  151. package/src/runtime/auth/route-policy.ts +2 -0
  152. package/src/runtime/gateway-client.ts +47 -4
  153. package/src/runtime/guardian-decision-types.ts +45 -4
  154. package/src/runtime/http-server.ts +5 -2
  155. package/src/runtime/routes/access-request-decision.ts +2 -2
  156. package/src/runtime/routes/app-management-routes.ts +2 -1
  157. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  158. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  159. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  160. package/src/runtime/routes/debug-routes.ts +12 -9
  161. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  162. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  163. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  164. package/src/runtime/routes/identity-routes.ts +1 -1
  165. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  168. package/src/runtime/routes/integrations/twilio.ts +52 -10
  169. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  170. package/src/runtime/routes/memory-item-routes.ts +25 -11
  171. package/src/runtime/routes/secret-routes.ts +141 -10
  172. package/src/runtime/routes/tts-routes.ts +11 -1
  173. package/src/security/ces-credential-client.ts +18 -9
  174. package/src/security/ces-rpc-credential-backend.ts +4 -3
  175. package/src/security/credential-backend.ts +10 -4
  176. package/src/security/secure-keys.ts +21 -4
  177. package/src/skills/catalog-install.ts +4 -36
  178. package/src/skills/skill-memory.ts +1 -0
  179. package/src/subagent/manager.ts +2 -5
  180. package/src/tools/acp/spawn.ts +78 -1
  181. package/src/tools/credentials/vault.ts +5 -3
  182. package/src/tools/memory/definitions.ts +3 -2
  183. package/src/tools/memory/handlers.ts +10 -7
  184. package/src/tools/terminal/safe-env.ts +1 -0
  185. package/src/util/browser.ts +15 -0
  186. package/src/util/platform.ts +1 -1
  187. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  188. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  189. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  190. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  191. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  192. package/src/workspace/migrations/registry.ts +4 -0
  193. package/src/workspace/provider-commit-message-generator.ts +12 -21
  194. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  195. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  196. package/src/memory/search/lexical.ts +0 -48
  197. package/src/providers/failover.ts +0 -186
@@ -22,6 +22,17 @@ import { deliverStaleApprovalReply } from "../guardian-approval-reply-helpers.js
22
22
 
23
23
  const log = getLogger("runtime-http");
24
24
 
25
+ /**
26
+ * Resolve the Slack ephemeral user ID when the source channel is Slack.
27
+ * Returns `undefined` for non-Slack channels.
28
+ */
29
+ function slackEphemeralUserId(
30
+ sourceChannel: ChannelId,
31
+ userId: string | undefined,
32
+ ): string | undefined {
33
+ return sourceChannel === "slack" && userId ? userId : undefined;
34
+ }
35
+
25
36
  export interface TextEngineDecisionParams {
26
37
  conversationId: string;
27
38
  conversationExternalId: string;
@@ -36,6 +47,8 @@ export interface TextEngineDecisionParams {
36
47
  pending: Array<{ requestId: string; toolName: string }>;
37
48
  /** Allowed actions from the pending prompt. */
38
49
  allowedActions: string[];
50
+ /** External user ID of the actor (for Slack ephemeral routing). */
51
+ actorExternalId?: string;
39
52
  }
40
53
 
41
54
  /**
@@ -58,6 +71,7 @@ export async function handleGuardianTextEngineDecision(
58
71
  approvalConversationGenerator,
59
72
  pending,
60
73
  allowedActions,
74
+ actorExternalId,
61
75
  } = params;
62
76
 
63
77
  const engineContext: ApprovalConversationContext = {
@@ -79,13 +93,19 @@ export async function handleGuardianTextEngineDecision(
79
93
  if (engineResult.disposition === "keep_pending") {
80
94
  // Non-decision follow-up — deliver the engine's reply and keep the request pending
81
95
  try {
96
+ const keepPendingPayload: Parameters<typeof deliverChannelReply>[1] = {
97
+ chatId: conversationExternalId,
98
+ text: engineResult.replyText,
99
+ assistantId,
100
+ };
101
+ const ephemeral = slackEphemeralUserId(sourceChannel, actorExternalId);
102
+ if (ephemeral) {
103
+ keepPendingPayload.ephemeral = true;
104
+ keepPendingPayload.user = ephemeral;
105
+ }
82
106
  await deliverChannelReply(
83
107
  replyCallbackUrl,
84
- {
85
- chatId: conversationExternalId,
86
- text: engineResult.replyText,
87
- assistantId,
88
- },
108
+ keepPendingPayload,
89
109
  bearerToken,
90
110
  );
91
111
  } catch (err) {
@@ -112,15 +132,17 @@ export async function handleGuardianTextEngineDecision(
112
132
  if (result.applied) {
113
133
  // Deliver the engine's reply text to the user
114
134
  try {
115
- await deliverChannelReply(
116
- replyCallbackUrl,
117
- {
118
- chatId: conversationExternalId,
119
- text: engineResult.replyText,
120
- assistantId,
121
- },
122
- bearerToken,
123
- );
135
+ const decisionPayload: Parameters<typeof deliverChannelReply>[1] = {
136
+ chatId: conversationExternalId,
137
+ text: engineResult.replyText,
138
+ assistantId,
139
+ };
140
+ const ephemeral = slackEphemeralUserId(sourceChannel, actorExternalId);
141
+ if (ephemeral) {
142
+ decisionPayload.ephemeral = true;
143
+ decisionPayload.user = ephemeral;
144
+ }
145
+ await deliverChannelReply(replyCallbackUrl, decisionPayload, bearerToken);
124
146
  } catch (err) {
125
147
  log.error(
126
148
  { err, conversationId },
@@ -145,6 +167,7 @@ export async function handleGuardianTextEngineDecision(
145
167
  logger: log,
146
168
  errorLogMessage: "Failed to deliver stale approval notice",
147
169
  errorLogContext: { conversationId },
170
+ ephemeralUserId: slackEphemeralUserId(sourceChannel, actorExternalId),
148
171
  });
149
172
 
150
173
  return { handled: true, type: "stale_ignored" };
@@ -63,10 +63,15 @@ export async function handleGetChannelReadiness(url: URL): Promise<Response> {
63
63
  export async function handleRefreshChannelReadiness(
64
64
  req: Request,
65
65
  ): Promise<Response> {
66
- const body = (await req.json().catch(() => ({}))) as {
67
- channel?: ChannelId;
68
- includeRemote?: boolean;
69
- };
66
+ let body: { channel?: ChannelId; includeRemote?: boolean };
67
+ try {
68
+ body = (await req.json()) as typeof body;
69
+ } catch {
70
+ return Response.json(
71
+ { success: false, error: "Invalid JSON in request body" },
72
+ { status: 400 },
73
+ );
74
+ }
70
75
 
71
76
  const service = getReadinessService();
72
77
 
@@ -8,7 +8,10 @@ import { getConfig } from "../../config/loader.js";
8
8
  import { countConversations } from "../../memory/conversation-queries.js";
9
9
  import { rawAll } from "../../memory/db.js";
10
10
  import { getMemoryJobCounts } from "../../memory/jobs-store.js";
11
- import { getProviderDebugStatus } from "../../providers/registry.js";
11
+ import {
12
+ getProviderRoutingSource,
13
+ listProviders,
14
+ } from "../../providers/registry.js";
12
15
  import { countSchedules } from "../../schedule/schedule-store.js";
13
16
  import { getDbPath } from "../../util/platform.js";
14
17
  import type { RouteDefinition } from "../http-router.js";
@@ -48,13 +51,11 @@ function handleDebug(): Response {
48
51
  const scheduleCounts = countSchedules();
49
52
 
50
53
  const config = getConfig();
51
- const providerOrder = Array.isArray(config.providerOrder)
52
- ? config.providerOrder
53
- : [];
54
- const providerStatus = getProviderDebugStatus(
55
- config.services.inference.provider,
56
- providerOrder,
57
- );
54
+ const registeredProviders = listProviders();
55
+ const routingSources: Record<string, string | undefined> = {};
56
+ for (const name of registeredProviders) {
57
+ routingSources[name] = getProviderRoutingSource(name);
58
+ }
58
59
 
59
60
  return Response.json({
60
61
  session: {
@@ -62,7 +63,9 @@ function handleDebug(): Response {
62
63
  startedAt: new Date(startedAt).toISOString(),
63
64
  },
64
65
  provider: {
65
- ...providerStatus,
66
+ configuredProvider: config.services.inference.provider,
67
+ registeredProviders,
68
+ routingSources,
66
69
  inferenceMode: config.services.inference.mode,
67
70
  },
68
71
  memory: {
@@ -42,6 +42,18 @@ import { deliverStaleApprovalReply } from "./guardian-approval-reply-helpers.js"
42
42
 
43
43
  const log = getLogger("runtime-http");
44
44
 
45
+ /**
46
+ * Resolve the Slack ephemeral user ID when the source channel is Slack.
47
+ * Returns `undefined` for non-Slack channels so callers can pass the
48
+ * result directly to `ephemeralUserId` without branching.
49
+ */
50
+ function slackEphemeralUserId(
51
+ sourceChannel: ChannelId,
52
+ userId: string | undefined,
53
+ ): string | undefined {
54
+ return sourceChannel === "slack" && userId ? userId : undefined;
55
+ }
56
+
45
57
  export interface ApprovalInterceptionParams {
46
58
  conversationId: string;
47
59
  callbackData?: string;
@@ -55,6 +67,8 @@ export interface ApprovalInterceptionParams {
55
67
  assistantId: string;
56
68
  approvalCopyGenerator?: ApprovalCopyGenerator;
57
69
  approvalConversationGenerator?: ApprovalConversationGenerator;
70
+ /** Original approval message timestamp (Slack ts) for editing after resolution. */
71
+ approvalMessageTs?: string;
58
72
  }
59
73
 
60
74
  export interface ApprovalInterceptionResult {
@@ -92,6 +106,7 @@ export async function handleApprovalInterception(
92
106
  assistantId,
93
107
  approvalCopyGenerator,
94
108
  approvalConversationGenerator,
109
+ approvalMessageTs,
95
110
  } = params;
96
111
 
97
112
  // ── Guardian approval decision path ──
@@ -110,6 +125,7 @@ export async function handleApprovalInterception(
110
125
  assistantId,
111
126
  approvalCopyGenerator,
112
127
  approvalConversationGenerator,
128
+ approvalMessageTs,
113
129
  });
114
130
  if (guardianResult) {
115
131
  return guardianResult;
@@ -262,13 +278,23 @@ export async function handleApprovalInterception(
262
278
  approvalCopyGenerator,
263
279
  ));
264
280
  try {
265
- await deliverChannelReply(
266
- replyCallbackUrl,
281
+ const cancelPayload: Parameters<typeof deliverChannelReply>[1] =
267
282
  {
268
283
  chatId: conversationExternalId,
269
284
  text: replyText,
270
285
  assistantId,
271
- },
286
+ };
287
+ const requesterEphemeral = slackEphemeralUserId(
288
+ sourceChannel,
289
+ actorExternalId,
290
+ );
291
+ if (requesterEphemeral) {
292
+ cancelPayload.ephemeral = true;
293
+ cancelPayload.user = requesterEphemeral;
294
+ }
295
+ await deliverChannelReply(
296
+ replyCallbackUrl,
297
+ cancelPayload,
272
298
  bearerToken,
273
299
  );
274
300
  } catch (err) {
@@ -290,13 +316,24 @@ export async function handleApprovalInterception(
290
316
  {},
291
317
  approvalCopyGenerator,
292
318
  );
319
+ const guardianCancelPayload: Parameters<
320
+ typeof deliverChannelReply
321
+ >[1] = {
322
+ chatId: guardianApprovalForRequest.guardianChatId,
323
+ text: guardianNotice,
324
+ assistantId,
325
+ };
326
+ const guardianEphemeral = slackEphemeralUserId(
327
+ sourceChannel,
328
+ guardianApprovalForRequest.guardianExternalUserId,
329
+ );
330
+ if (guardianEphemeral) {
331
+ guardianCancelPayload.ephemeral = true;
332
+ guardianCancelPayload.user = guardianEphemeral;
333
+ }
293
334
  await deliverChannelReply(
294
335
  replyCallbackUrl,
295
- {
296
- chatId: guardianApprovalForRequest.guardianChatId,
297
- text: guardianNotice,
298
- assistantId,
299
- },
336
+ guardianCancelPayload,
300
337
  bearerToken,
301
338
  );
302
339
  } catch (err) {
@@ -322,19 +359,33 @@ export async function handleApprovalInterception(
322
359
  errorLogMessage:
323
360
  "Failed to deliver stale requester-cancel notice",
324
361
  errorLogContext: { conversationId },
362
+ ephemeralUserId: slackEphemeralUserId(
363
+ sourceChannel,
364
+ actorExternalId,
365
+ ),
325
366
  });
326
367
  return { handled: true, type: "stale_ignored" };
327
368
  }
328
369
 
329
370
  if (requesterFollowupReplyText) {
330
371
  try {
331
- await deliverChannelReply(
332
- replyCallbackUrl,
372
+ const followupPayload: Parameters<typeof deliverChannelReply>[1] =
333
373
  {
334
374
  chatId: conversationExternalId,
335
375
  text: requesterFollowupReplyText,
336
376
  assistantId,
337
- },
377
+ };
378
+ const followupEphemeral = slackEphemeralUserId(
379
+ sourceChannel,
380
+ actorExternalId,
381
+ );
382
+ if (followupEphemeral) {
383
+ followupPayload.ephemeral = true;
384
+ followupPayload.user = followupEphemeral;
385
+ }
386
+ await deliverChannelReply(
387
+ replyCallbackUrl,
388
+ followupPayload,
338
389
  bearerToken,
339
390
  );
340
391
  } catch (err) {
@@ -360,6 +411,7 @@ export async function handleApprovalInterception(
360
411
  errorLogMessage:
361
412
  "Failed to deliver guardian-pending notice to requester",
362
413
  errorLogContext: { conversationId },
414
+ ephemeralUserId: slackEphemeralUserId(sourceChannel, actorExternalId),
363
415
  });
364
416
  return { handled: true, type: "assistant_turn" };
365
417
  }
@@ -394,6 +446,7 @@ export async function handleApprovalInterception(
394
446
  "Failed to deliver guardian-expiry notice to requester",
395
447
  extraContext: { toolName: pending[0].toolName },
396
448
  errorLogContext: { conversationId },
449
+ ephemeralUserId: slackEphemeralUserId(sourceChannel, actorExternalId),
397
450
  });
398
451
  return { handled: true, type: "decision_applied" };
399
452
  }
@@ -430,6 +483,7 @@ export async function handleApprovalInterception(
430
483
  errorLogMessage:
431
484
  "Failed to deliver guardian-pending notice to non-guardian actor (pre-row guard)",
432
485
  errorLogContext: { conversationId },
486
+ ephemeralUserId: slackEphemeralUserId(sourceChannel, actorExternalId),
433
487
  });
434
488
  return { handled: true, type: "assistant_turn" };
435
489
  }
@@ -478,6 +532,19 @@ export async function handleApprovalInterception(
478
532
  { conversationId, callbackRequestId: cbDecision.requestId },
479
533
  "Callback request ID does not match any pending interaction, ignoring stale button press",
480
534
  );
535
+
536
+ // Edit the original Slack approval message to remove stale buttons
537
+ if (sourceChannel === "slack" && approvalMessageTs) {
538
+ editStaleSlackApprovalMessage({
539
+ replyCallbackUrl,
540
+ chatId: conversationExternalId,
541
+ messageTs: approvalMessageTs,
542
+ assistantId,
543
+ bearerToken,
544
+ conversationId,
545
+ });
546
+ }
547
+
481
548
  return { handled: true, type: "stale_ignored" };
482
549
  }
483
550
  }
@@ -485,6 +552,32 @@ export async function handleApprovalInterception(
485
552
  const result = handleChannelDecision(conversationId, cbDecision);
486
553
 
487
554
  if (result.applied) {
555
+ // Edit the original Slack approval message to show the decision
556
+ // and remove stale action buttons.
557
+ if (sourceChannel === "slack" && approvalMessageTs) {
558
+ const decisionOutcome: "approved" | "denied" =
559
+ cbDecision.action === "reject" ? "denied" : "approved";
560
+ const statusEmoji =
561
+ decisionOutcome === "approved" ? "\u2713" : "\u2717";
562
+ const statusLabel =
563
+ decisionOutcome === "approved" ? "Approved" : "Denied";
564
+ deliverChannelReply(
565
+ replyCallbackUrl,
566
+ {
567
+ chatId: conversationExternalId,
568
+ text: `${statusEmoji} ${statusLabel}`,
569
+ messageTs: approvalMessageTs,
570
+ assistantId,
571
+ },
572
+ bearerToken,
573
+ ).catch((err) => {
574
+ log.error(
575
+ { err, conversationId, messageTs: approvalMessageTs },
576
+ "Failed to edit Slack approval message after decision",
577
+ );
578
+ });
579
+ }
580
+
488
581
  // Post-decision delivery is handled by the onEvent callback
489
582
  // in the session that registered the pending interaction.
490
583
  return { handled: true, type: "decision_applied" };
@@ -492,6 +585,18 @@ export async function handleApprovalInterception(
492
585
 
493
586
  // Race condition: request was already resolved between the stale check
494
587
  // above and the decision attempt.
588
+ // Edit the original Slack approval message to remove stale buttons
589
+ if (sourceChannel === "slack" && approvalMessageTs) {
590
+ editStaleSlackApprovalMessage({
591
+ replyCallbackUrl,
592
+ chatId: conversationExternalId,
593
+ messageTs: approvalMessageTs,
594
+ assistantId,
595
+ bearerToken,
596
+ conversationId,
597
+ });
598
+ }
599
+
495
600
  return { handled: true, type: "stale_ignored" };
496
601
  }
497
602
  }
@@ -514,6 +619,7 @@ export async function handleApprovalInterception(
514
619
  approvalConversationGenerator,
515
620
  pending,
516
621
  allowedActions,
622
+ actorExternalId,
517
623
  });
518
624
  }
519
625
 
@@ -556,7 +662,58 @@ export async function handleApprovalInterception(
556
662
  toolName: pending.length > 0 ? pending[0].toolName : undefined,
557
663
  },
558
664
  errorLogContext: { conversationId },
665
+ ephemeralUserId: slackEphemeralUserId(sourceChannel, actorExternalId),
559
666
  });
560
667
 
561
668
  return { handled: true, type: "assistant_turn" };
562
669
  }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Slack approval message edit helper
673
+ // ---------------------------------------------------------------------------
674
+
675
+ /**
676
+ * Fire-and-forget: edit a stale Slack approval message to indicate it has
677
+ * been resolved and remove the action buttons. Used when a button click
678
+ * arrives for an already-resolved approval.
679
+ */
680
+ function editStaleSlackApprovalMessage(params: {
681
+ replyCallbackUrl: string;
682
+ chatId: string;
683
+ messageTs: string;
684
+ assistantId: string;
685
+ bearerToken?: string;
686
+ conversationId: string;
687
+ }): void {
688
+ const statusText = "This approval request has been resolved.";
689
+ const blocks = [
690
+ {
691
+ type: "section",
692
+ text: { type: "mrkdwn", text: statusText },
693
+ },
694
+ {
695
+ type: "context",
696
+ elements: [{ type: "mrkdwn", text: statusText }],
697
+ },
698
+ ];
699
+ deliverChannelReply(
700
+ params.replyCallbackUrl,
701
+ {
702
+ chatId: params.chatId,
703
+ text: statusText,
704
+ blocks,
705
+ messageTs: params.messageTs,
706
+ assistantId: params.assistantId,
707
+ },
708
+ params.bearerToken,
709
+ ).catch((err) => {
710
+ log.error(
711
+ {
712
+ err,
713
+ conversationId: params.conversationId,
714
+ messageTs: params.messageTs,
715
+ },
716
+ "Failed to edit stale Slack approval message",
717
+ );
718
+ });
719
+ }
@@ -14,6 +14,7 @@ import {
14
14
  deliverApprovalPrompt,
15
15
  deliverChannelReply,
16
16
  } from "../gateway-client.js";
17
+ import { buildActionLegend } from "../guardian-decision-types.js";
17
18
  import type { ApprovalCopyGenerator } from "../http-types.js";
18
19
  import { requiredDecisionKeywords } from "./channel-route-shared.js";
19
20
 
@@ -60,11 +61,15 @@ export async function deliverGeneratedApprovalPrompt(
60
61
  approvalCopyGenerator,
61
62
  );
62
63
 
64
+ // Append a legend explaining what each button does
65
+ const legend = buildActionLegend(uiMetadata.actions);
66
+ const richTextWithLegend = legend ? `${richText}\n\n${legend}` : richText;
67
+
63
68
  try {
64
69
  await deliverApprovalPrompt(
65
70
  replyCallbackUrl,
66
71
  chatId,
67
- richText,
72
+ richTextWithLegend,
68
73
  uiMetadata,
69
74
  assistantId,
70
75
  bearerToken,
@@ -11,6 +11,25 @@ import { composeApprovalMessageGenerative } from "../approval-message-composer.j
11
11
  import { deliverChannelReply } from "../gateway-client.js";
12
12
  import type { ApprovalCopyGenerator } from "../http-types.js";
13
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Deduplication for "already resolved" ephemeral messages
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Tracks recently sent stale approval notifications to prevent flooding the
20
+ * user when they rapidly click stale approval buttons. Keyed by
21
+ * `${chatId}:${scenario}` with a 30-second TTL per entry.
22
+ */
23
+ const recentStaleNotifications = new Set<string>();
24
+
25
+ /** TTL in milliseconds for dedup entries. Exported for testing. */
26
+ export const STALE_DEDUP_TTL_MS = 30_000;
27
+
28
+ /** Clear the dedup cache. Exported for testing only. */
29
+ export function clearStaleNotificationCache(): void {
30
+ recentStaleNotifications.clear();
31
+ }
32
+
14
33
  interface DeliverApprovalReplyParams {
15
34
  context: ApprovalMessageContext;
16
35
  replyCallbackUrl: string;
@@ -22,14 +41,19 @@ interface DeliverApprovalReplyParams {
22
41
  errorLogMessage: string;
23
42
  /** Extra fields merged into the pino error context. */
24
43
  errorLogContext?: Record<string, unknown>;
44
+ /**
45
+ * When set, deliver via `chat.postEphemeral` so only this Slack user
46
+ * sees the message. Used to keep approval-related noise out of shared
47
+ * channels.
48
+ */
49
+ ephemeralUserId?: string;
25
50
  }
26
51
 
27
52
  /**
28
53
  * Compose a generative approval message and deliver it as a channel reply.
29
- * Swallows delivery errors and logs them callers don't need their own
30
- * try/catch blocks.
54
+ * Throws on failure callers decide whether to swallow or propagate.
31
55
  */
32
- async function deliverApprovalReply(
56
+ async function composeAndDeliver(
33
57
  params: DeliverApprovalReplyParams,
34
58
  ): Promise<void> {
35
59
  const {
@@ -39,24 +63,41 @@ async function deliverApprovalReply(
39
63
  assistantId,
40
64
  bearerToken,
41
65
  approvalCopyGenerator,
42
- logger,
43
- errorLogMessage,
44
- errorLogContext,
66
+ ephemeralUserId,
45
67
  } = params;
46
68
 
69
+ const text = await composeApprovalMessageGenerative(
70
+ context,
71
+ {},
72
+ approvalCopyGenerator,
73
+ );
74
+ const payload: Parameters<typeof deliverChannelReply>[1] = {
75
+ chatId,
76
+ text,
77
+ assistantId,
78
+ };
79
+ if (ephemeralUserId) {
80
+ payload.ephemeral = true;
81
+ payload.user = ephemeralUserId;
82
+ }
83
+ await deliverChannelReply(replyCallbackUrl, payload, bearerToken);
84
+ }
85
+
86
+ /**
87
+ * Compose a generative approval message and deliver it as a channel reply.
88
+ * Swallows delivery errors and logs them — callers don't need their own
89
+ * try/catch blocks.
90
+ */
91
+ async function deliverApprovalReply(
92
+ params: DeliverApprovalReplyParams,
93
+ ): Promise<void> {
47
94
  try {
48
- const text = await composeApprovalMessageGenerative(
49
- context,
50
- {},
51
- approvalCopyGenerator,
52
- );
53
- await deliverChannelReply(
54
- replyCallbackUrl,
55
- { chatId, text, assistantId },
56
- bearerToken,
57
- );
95
+ await composeAndDeliver(params);
58
96
  } catch (err) {
59
- logger.error({ err, ...errorLogContext }, errorLogMessage);
97
+ params.logger.error(
98
+ { err, ...params.errorLogContext },
99
+ params.errorLogMessage,
100
+ );
60
101
  }
61
102
  }
62
103
 
@@ -78,25 +119,60 @@ export interface DeliverStaleApprovalReplyParams {
78
119
  extraContext?: Partial<ApprovalMessageContext>;
79
120
  /** Extra fields merged into the pino error context. */
80
121
  errorLogContext?: Record<string, unknown>;
122
+ /**
123
+ * When set, deliver via `chat.postEphemeral` so only this Slack user
124
+ * sees the message. Keeps approval noise out of shared channels.
125
+ */
126
+ ephemeralUserId?: string;
81
127
  }
82
128
 
83
129
  /**
84
130
  * Deliver a stale/already-resolved approval notice to a channel chat.
85
131
  * Consolidates the repeated compose + deliver + try/catch pattern.
132
+ *
133
+ * For `approval_already_resolved` scenarios, deduplicates notifications
134
+ * per chat so rapid stale button clicks don't flood the user with
135
+ * repeated ephemeral warnings.
86
136
  */
87
137
  export async function deliverStaleApprovalReply(
88
138
  params: DeliverStaleApprovalReplyParams,
89
139
  ): Promise<void> {
90
- const { scenario, sourceChannel, extraContext, ...rest } = params;
140
+ const { scenario, sourceChannel, extraContext, ephemeralUserId, ...rest } =
141
+ params;
91
142
 
92
- await deliverApprovalReply({
143
+ const replyParams: DeliverApprovalReplyParams = {
93
144
  ...rest,
145
+ ephemeralUserId,
94
146
  context: {
95
147
  scenario,
96
148
  channel: sourceChannel,
97
149
  ...extraContext,
98
150
  },
99
- });
151
+ };
152
+
153
+ // Deduplicate "already resolved" ephemeral messages per chat.
154
+ // If the same (chatId, scenario) pair was notified within the TTL, skip.
155
+ if (scenario === "approval_already_resolved") {
156
+ const dedupeKey = `${rest.chatId}:${scenario}`;
157
+ if (recentStaleNotifications.has(dedupeKey)) {
158
+ return;
159
+ }
160
+
161
+ // Cache the dedup key only after successful delivery so that failures
162
+ // don't silently suppress retries for the TTL window.
163
+ try {
164
+ await composeAndDeliver(replyParams);
165
+ recentStaleNotifications.add(dedupeKey);
166
+ setTimeout(() => {
167
+ recentStaleNotifications.delete(dedupeKey);
168
+ }, STALE_DEDUP_TTL_MS);
169
+ } catch (err) {
170
+ rest.logger.error({ err, ...rest.errorLogContext }, rest.errorLogMessage);
171
+ }
172
+ return;
173
+ }
174
+
175
+ await deliverApprovalReply(replyParams);
100
176
  }
101
177
 
102
178
  // ---------------------------------------------------------------------------
@@ -114,6 +190,11 @@ export interface DeliverIdentityMismatchReplyParams {
114
190
  errorLogMessage: string;
115
191
  /** Extra fields merged into the pino error context. */
116
192
  errorLogContext?: Record<string, unknown>;
193
+ /**
194
+ * When set, deliver via `chat.postEphemeral` so only this Slack user
195
+ * sees the message.
196
+ */
197
+ ephemeralUserId?: string;
117
198
  }
118
199
 
119
200
  /**
@@ -123,10 +204,11 @@ export interface DeliverIdentityMismatchReplyParams {
123
204
  export async function deliverIdentityMismatchReply(
124
205
  params: DeliverIdentityMismatchReplyParams,
125
206
  ): Promise<void> {
126
- const { sourceChannel, ...rest } = params;
207
+ const { sourceChannel, ephemeralUserId, ...rest } = params;
127
208
 
128
209
  await deliverApprovalReply({
129
210
  ...rest,
211
+ ephemeralUserId,
130
212
  context: {
131
213
  scenario: "guardian_identity_mismatch",
132
214
  channel: sourceChannel,
@@ -89,7 +89,7 @@ interface CpuInfo {
89
89
  maxCores: number;
90
90
  }
91
91
 
92
- // Track CPU usage over a rolling window so /healthz reports near-real-time
92
+ // Track CPU usage over a rolling window so /v1/health reports near-real-time
93
93
  // utilization instead of a lifetime average (total CPU time / total uptime).
94
94
  const CPU_SAMPLE_INTERVAL_MS = 5_000;
95
95
  let _lastCpuUsage: NodeJS.CpuUsage = process.cpuUsage();