@vellumai/assistant 0.10.3 → 0.10.4-staging.1

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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -47,9 +47,8 @@ let nextTrust: ActorTrustContext;
47
47
  const resolveActorTrustMock = mock(() => nextTrust);
48
48
  // Override only `resolveActorTrust`; the real `trust-verdict-consumer` imports
49
49
  // `toTrustContext` from this module, so the rest must pass through untouched.
50
- const actorTrustResolverModule = await import(
51
- "../../runtime/actor-trust-resolver.js"
52
- );
50
+ const actorTrustResolverModule =
51
+ await import("../../runtime/actor-trust-resolver.js");
53
52
  mock.module("../../runtime/actor-trust-resolver.js", () => ({
54
53
  ...actorTrustResolverModule,
55
54
  resolveActorTrust: resolveActorTrustMock,
@@ -92,13 +91,7 @@ function makeChannel(overrides: Partial<ContactChannel> = {}): ContactChannel {
92
91
  address: "+12025550142",
93
92
  isPrimary: true,
94
93
  externalChatId: null,
95
- status: "active",
96
- policy: "allow",
97
- verifiedAt: null,
98
- verifiedVia: null,
99
94
  inviteId: null,
100
- revokedReason: null,
101
- blockedReason: null,
102
95
  lastSeenAt: null,
103
96
  interactionCount: 0,
104
97
  lastInteraction: null,
@@ -115,13 +108,12 @@ function makeContact(
115
108
  id: "ct_1",
116
109
  displayName: "Test Caller",
117
110
  notes: null,
111
+ role: "contact",
118
112
  lastInteraction: null,
119
113
  interactionCount: 0,
120
114
  createdAt: 0,
121
115
  updatedAt: 0,
122
- role: "contact",
123
116
  contactType: "human",
124
- principalId: null,
125
117
  userFile: null,
126
118
  channels: [],
127
119
  ...overrides,
@@ -138,11 +130,11 @@ function makeTrust(
138
130
  ): ActorTrustContext {
139
131
  const memberRecord = channel
140
132
  ? {
141
- contact: makeContact({ role: channel.role ?? "contact" }),
142
- channel: makeChannel({
143
- status: channel.status,
144
- policy: channel.policy ?? "allow",
145
- }),
133
+ contact: makeContact(),
134
+ channel: makeChannel(),
135
+ status: channel.status,
136
+ policy: channel.policy ?? "allow",
137
+ role: channel.role ?? "contact",
146
138
  }
147
139
  : null;
148
140
  return {
@@ -404,8 +396,8 @@ function makeVerdict(overrides: Partial<TrustVerdict> = {}): TrustVerdict {
404
396
  }
405
397
 
406
398
  // A verdict carrying a fully-resolvable member ACL (contactId/channelId + valid
407
- // known status·policy enums). The REAL `resolvedMemberFromVerdict` synthesizes
408
- // a memberRecord from these, so the verdict path enforces blocked/revoked/deny.
399
+ // known status·policy enums). The verdict path builds a memberRecord from
400
+ // these, so it enforces blocked/revoked/deny.
409
401
  function makeMemberVerdict(
410
402
  trustClass: TrustVerdict["trustClass"],
411
403
  channel: { status: string; policy?: string },
@@ -7,7 +7,10 @@
7
7
  * 3. Records guardian_action_delivery rows from pipeline delivery results
8
8
  */
9
9
 
10
- import { findGuardianForChannel } from "../contacts/contact-store.js";
10
+ import {
11
+ getGuardianDelivery,
12
+ guardianForChannel,
13
+ } from "../contacts/guardian-delivery-reader.js";
11
14
  import {
12
15
  createCanonicalGuardianRequest,
13
16
  listCanonicalGuardianDeliveries,
@@ -86,16 +89,16 @@ async function dispatchGuardianQuestionInner(
86
89
  try {
87
90
  const expiresAt = Date.now() + getUserConsultationTimeoutMs();
88
91
 
89
- // Resolve the request principal from the same local source as the
90
- // submitting actor (guardian-action-routes / actor-trust-resolver both read
91
- // findGuardianForChannel("vellum")?.contact.principalId), so they cannot
92
- // diverge. applyCanonicalGuardianDecision requires strict equality with
93
- // request.guardianPrincipalId; sharing this local source guarantees the
94
- // stamped principal == the submitting principal even when the gateway and
95
- // local bindings drift during migration. This pair converts to the gateway
96
- // together with the actor-trust path.
97
- const guardianPrincipalId =
98
- findGuardianForChannel("vellum")?.contact.principalId ?? undefined;
92
+ // Resolve the request principal from the gateway guardian delivery the
93
+ // same source the submitting actor (guardian-action-routes /
94
+ // actor-trust-resolver) resolves, so they cannot diverge.
95
+ // applyCanonicalGuardianDecision requires strict equality with
96
+ // request.guardianPrincipalId; sharing this gateway source guarantees the
97
+ // stamped principal == the submitting principal.
98
+ const guardians = await getGuardianDelivery({ channelTypes: ["vellum"] });
99
+ const guardianPrincipalId = guardians
100
+ ? (guardianForChannel(guardians, "vellum")?.principalId ?? undefined)
101
+ : undefined;
99
102
 
100
103
  if (!guardianPrincipalId) {
101
104
  log.error(
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  import type { ChannelId } from "../channels/types.js";
19
19
  import { ipcCall } from "../ipc/gateway-client.js";
20
+ import { setMemberVerdict } from "../runtime/member-verdict-cache.js";
20
21
 
21
22
  // Short IPC timeout so the read resolves promptly rather than stalling call
22
23
  // setup on a gateway that accepts the socket but hangs.
@@ -36,7 +37,12 @@ export async function getInboundTrustVerdict(input: {
36
37
  if (!result) return null;
37
38
 
38
39
  const parsed = TrustVerdictSchema.safeParse(result.verdict);
39
- return parsed.success ? parsed.data : null;
40
+ if (!parsed.success) return null;
41
+
42
+ // Single choke point: warm the member-verdict cache so the sync trust
43
+ // fallback resolves the member without a local ACL read.
44
+ setMemberVerdict(input.channelType, input.actorExternalId, parsed.data);
45
+ return parsed.data;
40
46
  } catch {
41
47
  return null;
42
48
  }
@@ -9,6 +9,7 @@
9
9
  import { findContactChannel } from "../contacts/contact-store.js";
10
10
  import { getCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
11
11
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
12
+ import { getCachedMemberAcl } from "../runtime/member-verdict-cache.js";
12
13
  import { getLogger } from "../util/logger.js";
13
14
  import {
14
15
  getGuardianWaitUpdateInitialIntervalMs,
@@ -252,12 +253,11 @@ export function emitAccessRequestCallbackHandoff(
252
253
  address: fromNumber,
253
254
  externalChatId: fromNumber,
254
255
  });
255
- if (
256
- contactResult &&
257
- contactResult.channel.status === "active" &&
258
- contactResult.channel.policy === "allow"
259
- ) {
260
- requesterMemberId = contactResult.channel.id;
256
+ if (contactResult) {
257
+ const acl = getCachedMemberAcl("phone", fromNumber);
258
+ if (acl?.status === "active" && acl.policy === "allow") {
259
+ requesterMemberId = contactResult.channel.id;
260
+ }
261
261
  }
262
262
  } catch (err) {
263
263
  log.warn(
@@ -13,6 +13,7 @@ import type { ServerWebSocket } from "bun";
13
13
 
14
14
  import {
15
15
  getGuardianDelivery,
16
+ getGuardianDeliveryFresh,
16
17
  voiceGuardianDisplayName,
17
18
  } from "../contacts/guardian-delivery-reader.js";
18
19
  import { getAssistantName } from "../daemon/identity-helpers.js";
@@ -601,7 +602,19 @@ export class RelayConnection {
601
602
  const session = getCallSession(this.callSessionId);
602
603
  this.recordSetupBookkeeping(session, msg);
603
604
 
604
- await this.primeGuardianDisplayName();
605
+ // Prime the guardian displayName and warm the phone-channel guardian-
606
+ // delivery cache before routeSetup's SYNC resolveActorTrust fallback. That
607
+ // fallback reads the IO-free cache snapshot keyed per channel-filter; daemon
608
+ // startup warms only `vellum`, so a cold-process phone call would otherwise
609
+ // misclassify a guardian during a gateway verdict blip. Read fresh: gateway-
610
+ // side binding writes don't invalidate the daemon cache, so a stale empty
611
+ // snapshot from an earlier setup would survive the TTL otherwise. Both are
612
+ // independent IPC reads on different cache keys, so run them concurrently
613
+ // off the sync resolver's hot path.
614
+ await Promise.all([
615
+ this.primeGuardianDisplayName(),
616
+ getGuardianDeliveryFresh({ channelTypes: ["phone"] }),
617
+ ]);
605
618
 
606
619
  // Resolve the phone channel's inbound admission floor. The reader fails
607
620
  // open to `null` by contract, so a transport hiccup admits the caller.
@@ -811,7 +824,7 @@ export class RelayConnection {
811
824
  from,
812
825
  trustClass: resolved.actorTrust.trustClass,
813
826
  channelId: resolved.actorTrust.memberRecord?.channel.id,
814
- memberPolicy: resolved.actorTrust.memberRecord?.channel.policy,
827
+ memberPolicy: resolved.actorTrust.memberRecord?.policy,
815
828
  });
816
829
  this.connectionState = "disconnecting";
817
830
  updateCallSession(this.callSessionId, {
@@ -940,6 +953,13 @@ export class RelayConnection {
940
953
  });
941
954
  }
942
955
 
956
+ // Warm the phone-channel guardian-delivery cache before the SYNC
957
+ // resolveActorTrust fallback, which reads the IO-free per-channel snapshot
958
+ // that daemon startup leaves cold for `phone`. Read fresh: gateway-side
959
+ // binding writes don't invalidate the daemon cache, so a stale empty
960
+ // snapshot would otherwise survive the TTL and misclassify the guardian.
961
+ await getGuardianDeliveryFresh({ channelTypes: ["phone"] });
962
+
943
963
  return toTrustContext(
944
964
  resolveActorTrust({
945
965
  assistantId,
@@ -246,7 +246,7 @@ export function routeSetup(ctx: SetupContext): {
246
246
  ? enforceAdmissionPolicy({
247
247
  sourceChannel: "phone",
248
248
  trustClass: actorTrust.trustClass,
249
- memberStatus: actorTrust.memberRecord?.channel.status,
249
+ memberStatus: actorTrust.memberRecord?.status,
250
250
  policy: ctx.admissionPolicy!,
251
251
  })
252
252
  : ({ admitted: true } as const);
@@ -283,7 +283,7 @@ export function routeSetup(ctx: SetupContext): {
283
283
  !pendingChallenge
284
284
  ) {
285
285
  // Check for blocked caller
286
- if (actorTrust.memberRecord?.channel.status === "blocked") {
286
+ if (actorTrust.memberRecord?.status === "blocked") {
287
287
  log.info(
288
288
  {
289
289
  callSessionId: ctx.callSessionId,
@@ -366,14 +366,14 @@ export function routeSetup(ctx: SetupContext): {
366
366
  // gateway and assistant DBs) still get useful guidance instead of
367
367
  // the "I don't recognize this number" name-capture script.
368
368
  const unverifiedStatuses = new Set(["unverified", "pending"]);
369
- const memberChannel = actorTrust.memberRecord?.channel;
370
- if (memberChannel && unverifiedStatuses.has(memberChannel.status)) {
369
+ const member = actorTrust.memberRecord;
370
+ if (member && unverifiedStatuses.has(member.status)) {
371
371
  log.info(
372
372
  {
373
373
  callSessionId: ctx.callSessionId,
374
374
  from: ctx.from,
375
- channelId: memberChannel.id,
376
- channelStatus: memberChannel.status,
375
+ channelId: member.channel.id,
376
+ channelStatus: member.status,
377
377
  },
378
378
  "Inbound voice ACL: known but unverified caller — returning verification guidance",
379
379
  );
@@ -382,8 +382,8 @@ export function routeSetup(ctx: SetupContext): {
382
382
  action: "unverified_caller",
383
383
  assistantId,
384
384
  fromNumber: ctx.from,
385
- displayName: actorTrust.memberRecord!.contact.displayName,
386
- isGuardian: actorTrust.memberRecord!.contact.role === "guardian",
385
+ displayName: member.contact.displayName,
386
+ isGuardian: member.role === "guardian",
387
387
  },
388
388
  resolved,
389
389
  };
@@ -409,7 +409,7 @@ export function routeSetup(ctx: SetupContext): {
409
409
  }
410
410
 
411
411
  // Members with policy: 'deny'
412
- if (actorTrust.memberRecord?.channel.policy === "deny") {
412
+ if (actorTrust.memberRecord?.policy === "deny") {
413
413
  log.info(
414
414
  {
415
415
  callSessionId: ctx.callSessionId,
@@ -430,7 +430,7 @@ export function routeSetup(ctx: SetupContext): {
430
430
  }
431
431
 
432
432
  // Members with policy: 'escalate' — live calls can't wait for approval
433
- if (actorTrust.memberRecord?.channel.policy === "escalate") {
433
+ if (actorTrust.memberRecord?.policy === "escalate") {
434
434
  log.info(
435
435
  {
436
436
  callSessionId: ctx.callSessionId,
@@ -142,6 +142,7 @@ mock.module("../../../util/logger.js", () => ({
142
142
  truncateForLog: (value: string) => value,
143
143
  pruneOldLogFiles: () => 0,
144
144
  LOG_FILE_PATTERN: /^assistant-(\d{4}-\d{2}-\d{2})\.log$/,
145
+ getCurrentLogFilePath: () => "/tmp/test-assistant.log",
145
146
  }));
146
147
 
147
148
  const { registerConversationsCommand } = await import("../conversations.js");
@@ -8,13 +8,16 @@ import { shouldOutputJson, writeOutput } from "../output.js";
8
8
  // IPC response shapes
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
+ // ACL fields (role, status, policy) are gateway-owned and not hydrated by the
12
+ // daemon-native filtered reads (`--query`/`--channel-address`/`--channel-type`),
13
+ // so they are optional here. The unfiltered default read carries them.
11
14
  interface ContactChannel {
12
15
  id: string;
13
16
  contactId: string;
14
17
  type: string;
15
18
  address: string;
16
- status: string;
17
- policy: string;
19
+ status?: string;
20
+ policy?: string;
18
21
  isPrimary?: boolean;
19
22
  revokedReason?: string | null;
20
23
  blockedReason?: string | null;
@@ -23,7 +26,7 @@ interface ContactChannel {
23
26
  interface ContactWithChannels {
24
27
  id: string;
25
28
  displayName: string;
26
- role: string;
29
+ role?: string;
27
30
  contactType: string;
28
31
  notes?: string;
29
32
  principalId?: string;
@@ -56,7 +59,7 @@ function formatContactTable(contacts: ContactWithChannels[]): string {
56
59
  const rows = contacts.map((c) => [
57
60
  c.id,
58
61
  c.displayName,
59
- `${c.role}/${c.contactType}`,
62
+ `${c.role ?? "—"}/${c.contactType}`,
60
63
  String(c.channels.length),
61
64
  ]);
62
65
 
@@ -81,8 +84,8 @@ function formatChannelTable(channels: ContactChannel[]): string {
81
84
  const rows = channels.map((ch) => {
82
85
  const flags = [
83
86
  ch.isPrimary ? "primary" : null,
84
- ch.status !== "active" ? ch.status : null,
85
- ch.policy !== "allow" ? ch.policy : null,
87
+ ch.status && ch.status !== "active" ? ch.status : null,
88
+ ch.policy && ch.policy !== "allow" ? ch.policy : null,
86
89
  ]
87
90
  .filter(Boolean)
88
91
  .join(", ");
@@ -124,7 +127,7 @@ function formatContactDetail(
124
127
  const lines: string[] = [];
125
128
  lines.push(`ID: ${c.id}`);
126
129
  lines.push(`Display Name: ${c.displayName}`);
127
- lines.push(`Role: ${c.role}`);
130
+ if (c.role) lines.push(`Role: ${c.role}`);
128
131
  lines.push(`Type: ${c.contactType}`);
129
132
  if (c.notes) lines.push(`Notes: ${c.notes}`);
130
133
  if (c.principalId) lines.push(`Principal: ${c.principalId}`);
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Validates:
5
5
  * - Subcommand registration (start, stop, status) under `memory worker`.
6
- * - `status` reports running/not_running via PID-file liveness.
7
- * - `stop` sends SIGTERM to a live worker and errors when none is running.
8
- * - `start` refuses to spawn when a worker is already running, and reports
9
- * the PID once the spawned process writes its PID file.
6
+ * - `status` reports the worker process state, `memory.worker.enabled`, and
7
+ * the synchronous in-process runner via PID/marker-file liveness.
8
+ * - `stop` sends SIGTERM to a live worker and disables the config flag, and
9
+ * still disables the flag (success) when no worker is running.
10
+ * - `start` spawns/reuses the worker process and enables the config flag.
10
11
  */
11
12
 
12
13
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -22,17 +23,51 @@ import { Command } from "commander";
22
23
 
23
24
  let tmpDir: string;
24
25
  let pidPath: string;
26
+ let markerPath: string;
25
27
  let logOutput: string[] = [];
26
28
 
29
+ /** In-memory stand-in for the on-disk raw config the CLI reads/writes. */
30
+ let rawConfig: Record<string, unknown> = {};
31
+
27
32
  /** Records (pid, signal) pairs passed to the mocked process.kill. */
28
33
  let killCalls: Array<{ pid: number; signal: string | number }> = [];
29
34
 
35
+ function workerEnabledFromRaw(): boolean {
36
+ const memory = rawConfig.memory as { worker?: { enabled?: unknown } };
37
+ return memory?.worker?.enabled === true;
38
+ }
39
+
30
40
  // ---------------------------------------------------------------------------
31
41
  // Mocks
32
42
  // ---------------------------------------------------------------------------
33
43
 
34
44
  mock.module("../../../../util/platform.js", () => ({
35
45
  getMemoryWorkerPidPath: () => pidPath,
46
+ getMemorySyncRunnerMarkerPath: () => markerPath,
47
+ }));
48
+
49
+ mock.module("../../../../config/loader.js", () => ({
50
+ loadRawConfig: () => structuredClone(rawConfig),
51
+ saveRawConfig: (config: Record<string, unknown>) => {
52
+ rawConfig = structuredClone(config);
53
+ },
54
+ getConfigReadOnly: () => ({
55
+ memory: { worker: { enabled: workerEnabledFromRaw() } },
56
+ }),
57
+ setNestedValue: (
58
+ obj: Record<string, unknown>,
59
+ path: string,
60
+ value: unknown,
61
+ ) => {
62
+ const keys = path.split(".");
63
+ let cur = obj;
64
+ for (let i = 0; i < keys.length - 1; i++) {
65
+ const k = keys[i];
66
+ if (cur[k] == null || typeof cur[k] !== "object") cur[k] = {};
67
+ cur = cur[k] as Record<string, unknown>;
68
+ }
69
+ cur[keys[keys.length - 1]] = value;
70
+ },
36
71
  }));
37
72
 
38
73
  const capture = (...args: unknown[]) => {
@@ -47,6 +82,7 @@ const fakeLogger = {
47
82
  mock.module("../../../../util/logger.js", () => ({
48
83
  getLogger: () => fakeLogger,
49
84
  getCliLogger: () => fakeLogger,
85
+ getCurrentLogFilePath: () => `${tmpDir}/assistant-test-mock.log`,
50
86
  }));
51
87
 
52
88
  // ---------------------------------------------------------------------------
@@ -134,8 +170,10 @@ function stubProcessKill(livePids: Set<number>): () => void {
134
170
  beforeEach(() => {
135
171
  tmpDir = mkdtempSync(join(tmpdir(), "memory-worker-test-"));
136
172
  pidPath = join(tmpDir, "memory-worker.pid");
173
+ markerPath = join(tmpDir, "memory-sync-runner.pid");
137
174
  logOutput = [];
138
175
  killCalls = [];
176
+ rawConfig = {};
139
177
  process.exitCode = 0;
140
178
  });
141
179
 
@@ -164,7 +202,7 @@ describe("subcommand registration", () => {
164
202
  // ---------------------------------------------------------------------------
165
203
 
166
204
  describe("memory worker status", () => {
167
- test("reports not_running when no PID file exists", async () => {
205
+ test("reports not_running with workerEnabled and syncRunner when nothing is running", async () => {
168
206
  const { exitCode, stdout } = await runCommand([
169
207
  "memory",
170
208
  "worker",
@@ -172,7 +210,11 @@ describe("memory worker status", () => {
172
210
  "--json",
173
211
  ]);
174
212
  expect(exitCode).toBe(0);
175
- expect(JSON.parse(stdout)).toEqual({ status: "not_running" });
213
+ expect(JSON.parse(stdout)).toEqual({
214
+ status: "not_running",
215
+ workerEnabled: false,
216
+ syncRunner: { status: "not_running" },
217
+ });
176
218
  });
177
219
 
178
220
  test("reports running when PID file points at a live process", async () => {
@@ -189,6 +231,45 @@ describe("memory worker status", () => {
189
231
  expect(JSON.parse(stdout)).toEqual({
190
232
  status: "running",
191
233
  pid: process.pid,
234
+ workerEnabled: false,
235
+ syncRunner: { status: "not_running" },
236
+ });
237
+ } finally {
238
+ restore();
239
+ }
240
+ });
241
+
242
+ test("reflects memory.worker.enabled from config", async () => {
243
+ rawConfig = { memory: { worker: { enabled: true } } };
244
+ const restore = stubProcessKill(new Set());
245
+ try {
246
+ const { exitCode, stdout } = await runCommand([
247
+ "memory",
248
+ "worker",
249
+ "status",
250
+ "--json",
251
+ ]);
252
+ expect(exitCode).toBe(0);
253
+ expect(JSON.parse(stdout)).toMatchObject({ workerEnabled: true });
254
+ } finally {
255
+ restore();
256
+ }
257
+ });
258
+
259
+ test("reports the synchronous runner as running when its marker is live", async () => {
260
+ writeFileSync(markerPath, String(process.pid));
261
+ const restore = stubProcessKill(new Set([process.pid]));
262
+ try {
263
+ const { exitCode, stdout } = await runCommand([
264
+ "memory",
265
+ "worker",
266
+ "status",
267
+ "--json",
268
+ ]);
269
+ expect(exitCode).toBe(0);
270
+ expect(JSON.parse(stdout)).toMatchObject({
271
+ status: "not_running",
272
+ syncRunner: { status: "running", pid: process.pid },
192
273
  });
193
274
  } finally {
194
275
  restore();
@@ -206,7 +287,11 @@ describe("memory worker status", () => {
206
287
  "--json",
207
288
  ]);
208
289
  expect(exitCode).toBe(0);
209
- expect(JSON.parse(stdout)).toEqual({ status: "not_running" });
290
+ expect(JSON.parse(stdout)).toEqual({
291
+ status: "not_running",
292
+ workerEnabled: false,
293
+ syncRunner: { status: "not_running" },
294
+ });
210
295
  expect(existsSync(pidPath)).toBe(false);
211
296
  } finally {
212
297
  restore();
@@ -219,18 +304,25 @@ describe("memory worker status", () => {
219
304
  // ---------------------------------------------------------------------------
220
305
 
221
306
  describe("memory worker stop", () => {
222
- test("errors with exit 1 when no worker is running", async () => {
307
+ test("disables the config flag and succeeds when no worker is running", async () => {
308
+ rawConfig = { memory: { worker: { enabled: true } } };
223
309
  const { exitCode, stdout } = await runCommand([
224
310
  "memory",
225
311
  "worker",
226
312
  "stop",
227
313
  "--json",
228
314
  ]);
229
- expect(exitCode).toBe(1);
230
- expect(JSON.parse(stdout)).toMatchObject({ ok: false });
315
+ expect(exitCode).toBe(0);
316
+ expect(JSON.parse(stdout)).toMatchObject({
317
+ ok: true,
318
+ workerWasRunning: false,
319
+ workerEnabled: false,
320
+ });
321
+ expect(workerEnabledFromRaw()).toBe(false);
231
322
  });
232
323
 
233
- test("sends SIGTERM to a running worker", async () => {
324
+ test("sends SIGTERM to a running worker and disables the flag", async () => {
325
+ rawConfig = { memory: { worker: { enabled: true } } };
234
326
  writeFileSync(pidPath, String(process.pid));
235
327
  const restore = stubProcessKill(new Set([process.pid]));
236
328
  try {
@@ -241,11 +333,16 @@ describe("memory worker stop", () => {
241
333
  "--json",
242
334
  ]);
243
335
  expect(exitCode).toBe(0);
244
- expect(JSON.parse(stdout)).toEqual({ ok: true, pid: process.pid });
336
+ expect(JSON.parse(stdout)).toEqual({
337
+ ok: true,
338
+ pid: process.pid,
339
+ workerEnabled: false,
340
+ });
245
341
  expect(killCalls).toContainEqual({
246
342
  pid: process.pid,
247
343
  signal: "SIGTERM",
248
344
  });
345
+ expect(workerEnabledFromRaw()).toBe(false);
249
346
  } finally {
250
347
  restore();
251
348
  }
@@ -257,7 +354,7 @@ describe("memory worker stop", () => {
257
354
  // ---------------------------------------------------------------------------
258
355
 
259
356
  describe("memory worker start", () => {
260
- test("refuses to start when a worker is already running", async () => {
357
+ test("reuses an already-running worker and enables the flag", async () => {
261
358
  writeFileSync(pidPath, String(process.pid));
262
359
  const restore = stubProcessKill(new Set([process.pid]));
263
360
  try {
@@ -267,17 +364,20 @@ describe("memory worker start", () => {
267
364
  "start",
268
365
  "--json",
269
366
  ]);
270
- expect(exitCode).toBe(1);
367
+ expect(exitCode).toBe(0);
271
368
  expect(JSON.parse(stdout)).toMatchObject({
272
- ok: false,
369
+ ok: true,
273
370
  pid: process.pid,
371
+ alreadyRunning: true,
372
+ workerEnabled: true,
274
373
  });
374
+ expect(workerEnabledFromRaw()).toBe(true);
275
375
  } finally {
276
376
  restore();
277
377
  }
278
378
  });
279
379
 
280
- test("spawns the worker and reports the PID it writes", async () => {
380
+ test("spawns the worker, reports its PID, and enables the flag", async () => {
281
381
  const restore = stubProcessKill(new Set());
282
382
  const originalSpawn = Bun.spawn;
283
383
  // Simulate the spawned worker writing its PID file on startup.
@@ -293,7 +393,37 @@ describe("memory worker start", () => {
293
393
  "--json",
294
394
  ]);
295
395
  expect(exitCode).toBe(0);
296
- expect(JSON.parse(stdout)).toMatchObject({ ok: true, pid: 424242 });
396
+ expect(JSON.parse(stdout)).toMatchObject({
397
+ ok: true,
398
+ pid: 424242,
399
+ workerEnabled: true,
400
+ });
401
+ expect(workerEnabledFromRaw()).toBe(true);
402
+ } finally {
403
+ (Bun as { spawn: typeof Bun.spawn }).spawn = originalSpawn;
404
+ restore();
405
+ }
406
+ });
407
+
408
+ test("leaves the config flag untouched when the spawn fails", async () => {
409
+ const restore = stubProcessKill(new Set());
410
+ const originalSpawn = Bun.spawn;
411
+ // Spawn returns but never writes a PID file → spawnMemoryWorkerProcess
412
+ // throws after its wait loop, so the flag must stay disabled.
413
+ (Bun as { spawn: typeof Bun.spawn }).spawn = (() => ({
414
+ unref: () => {},
415
+ pid: 0,
416
+ })) as unknown as typeof Bun.spawn;
417
+ try {
418
+ const { exitCode, stdout } = await runCommand([
419
+ "memory",
420
+ "worker",
421
+ "start",
422
+ "--json",
423
+ ]);
424
+ expect(exitCode).toBe(1);
425
+ expect(JSON.parse(stdout)).toMatchObject({ ok: false });
426
+ expect(workerEnabledFromRaw()).toBe(false);
297
427
  } finally {
298
428
  (Bun as { spawn: typeof Bun.spawn }).spawn = originalSpawn;
299
429
  restore();