@vellumai/assistant 0.11.4-staging.1 → 0.11.4-staging.3

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 (205) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/knip.json +3 -0
  7. package/openapi.yaml +135 -75
  8. package/package.json +1 -1
  9. package/scripts/write-plugin-api-shim.ts +10 -0
  10. package/src/__tests__/app-control-flow.test.ts +1 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -2
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  13. package/src/__tests__/byok-default-profile-ensure.test.ts +15 -0
  14. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  15. package/src/__tests__/compaction-events.test.ts +8 -10
  16. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  17. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  18. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +99 -0
  20. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  22. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  23. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  25. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  26. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  29. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  31. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  32. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  35. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  37. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  39. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  40. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  41. package/src/__tests__/document-sync-tags.test.ts +0 -75
  42. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  43. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  44. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  45. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  46. package/src/__tests__/media-generate-image.test.ts +131 -21
  47. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  48. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  49. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  50. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  51. package/src/__tests__/qdrant-manager.test.ts +14 -1
  52. package/src/__tests__/run-due-schedules.test.ts +21 -0
  53. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  54. package/src/__tests__/schedule-routes.test.ts +23 -0
  55. package/src/__tests__/schedule-store.test.ts +17 -0
  56. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  57. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  58. package/src/__tests__/starter-task-flow.test.ts +5 -4
  59. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  61. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  62. package/src/__tests__/subagent-tools.test.ts +81 -101
  63. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  64. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  65. package/src/__tests__/ui-shape-teaching.test.ts +33 -0
  66. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  69. package/src/__tests__/voice-config-update.test.ts +40 -0
  70. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  71. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  72. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  73. package/src/acp/session-manager.ts +8 -1
  74. package/src/api/surfaces.ts +12 -3
  75. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  76. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  77. package/src/calls/call-controller.ts +16 -7
  78. package/src/calls/voice-session-bridge.ts +44 -25
  79. package/src/calls/voice-triage-escalate.ts +1 -0
  80. package/src/cli/bundled-modules.ts +29 -0
  81. package/src/cli/commands/db/repair.ts +4 -8
  82. package/src/cli/commands/domain.ts +6 -3
  83. package/src/cli/commands/email.ts +6 -3
  84. package/src/cli/commands/keys.ts +8 -3
  85. package/src/cli/commands/plugins.ts +85 -36
  86. package/src/cli/commands/schedules.ts +35 -1
  87. package/src/cli/lib/bundled-marketplace.json +1 -1
  88. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  89. package/src/config/assistant-feature-flags.ts +36 -15
  90. package/src/config/balanced-model-experiment.ts +35 -0
  91. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  92. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  93. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  94. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  95. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  96. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  97. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  98. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  99. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  100. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  101. package/src/config/call-site-defaults.ts +7 -0
  102. package/src/config/default-profile-catalog.ts +97 -5
  103. package/src/config/feature-flag-registry.json +11 -11
  104. package/src/config/skills.ts +9 -2
  105. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  106. package/src/daemon/conversation-agent-loop.ts +14 -13
  107. package/src/daemon/conversation-notifiers.ts +11 -9
  108. package/src/daemon/conversation-store.ts +4 -4
  109. package/src/daemon/conversation-surfaces.ts +54 -18
  110. package/src/daemon/conversation-tool-setup.ts +2 -4
  111. package/src/daemon/conversation.ts +81 -44
  112. package/src/daemon/doordash-steps.ts +2 -2
  113. package/src/daemon/lifecycle.ts +14 -1
  114. package/src/daemon/process-message.ts +0 -23
  115. package/src/daemon/windows-compiled-entry.ts +4 -0
  116. package/src/documents/document-store.ts +5 -235
  117. package/src/hooks/types.ts +5 -0
  118. package/src/ipc/gateway-flag-listener.ts +17 -3
  119. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  120. package/src/live-voice/live-voice-manager.ts +16 -3
  121. package/src/live-voice/live-voice-session.ts +63 -9
  122. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  123. package/src/monitoring/control.ts +1 -0
  124. package/src/monitoring/db-integrity-sample.ts +4 -5
  125. package/src/permissions/prompter.ts +1 -5
  126. package/src/persistence/conversation-queries.ts +66 -16
  127. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  128. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  129. package/src/persistence/schema/documents.ts +4 -4
  130. package/src/plugin-api/constants.ts +8 -0
  131. package/src/plugin-api/index.ts +5 -1
  132. package/src/plugin-api/vision-support.test.ts +1 -1
  133. package/src/plugin-api/webhook-url.ts +13 -11
  134. package/src/plugins/defaults/main.ts +6 -7
  135. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  136. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  137. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  138. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  139. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  140. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  141. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  142. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  143. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  144. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  145. package/src/plugins/mtime-cache.ts +17 -0
  146. package/src/prompts/templates/system-sections.ts +0 -7
  147. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  148. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  149. package/src/providers/model-catalog.ts +3 -3
  150. package/src/providers/model-intents.ts +2 -2
  151. package/src/providers/openai/chat-completions-provider.ts +16 -7
  152. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  153. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  154. package/src/routes/control.ts +1 -0
  155. package/src/routes/route-host-client.ts +1 -0
  156. package/src/runtime/AGENTS.md +16 -17
  157. package/src/runtime/agent-wake.ts +15 -12
  158. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  159. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  160. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  161. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  162. package/src/runtime/routes/conversation-routes.ts +11 -13
  163. package/src/runtime/routes/documents-routes.ts +3 -222
  164. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  165. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  166. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  167. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  168. package/src/runtime/routes/schedule-routes.ts +94 -7
  169. package/src/runtime/routes/workspace-routes.ts +0 -9
  170. package/src/runtime/routes/workspace-utils.ts +3 -13
  171. package/src/runtime/services/conversation-serializer.ts +7 -2
  172. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  173. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  174. package/src/schedule/plugin-schedule-availability.ts +58 -0
  175. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  176. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  177. package/src/schedule/schedule-store.ts +5 -1
  178. package/src/schedule/scheduler.ts +9 -4
  179. package/src/schedule/worker-control.ts +1 -0
  180. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  181. package/src/subagent/consult-context.ts +11 -11
  182. package/src/subagent/consult-prompt.ts +26 -35
  183. package/src/subagent/manager.ts +20 -37
  184. package/src/subagent/notify.ts +7 -1
  185. package/src/subagent/types.ts +8 -6
  186. package/src/tools/acp/spawn.ts +6 -4
  187. package/src/tools/skills/scaffold-managed.ts +25 -7
  188. package/src/tools/subagent/spawn.ts +28 -88
  189. package/src/tools/ui-surface/surface-shape-docs.ts +12 -1
  190. package/src/tts/__tests__/reasoning-tag-filter.test.ts +15 -0
  191. package/src/tts/reasoning-tag-filter.ts +11 -32
  192. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  193. package/src/util/logger.ts +16 -0
  194. package/src/util/think-tag-stream.ts +95 -0
  195. package/src/util/worker-process.ts +37 -4
  196. package/src/windows-compiled-cli.ts +32 -0
  197. package/src/windows-compiled-entry.ts +4 -0
  198. package/src/windows-compiled-logger.ts +6 -0
  199. package/src/windows-compiled-worker-entry.ts +29 -0
  200. package/src/workspace/byok-default-profile-ensure.ts +15 -6
  201. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  202. package/src/workspace/migrations/registry.ts +2 -0
  203. package/src/__tests__/document-workspace-file.test.ts +0 -467
  204. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  205. package/src/subagent/consult-transcript.ts +0 -90
@@ -72,6 +72,7 @@ mock.module("../../persistence/conversation-crud.js", () => ({
72
72
 
73
73
  import { setConfig } from "../../__tests__/helpers/set-config.js";
74
74
  import { ABORT_WATCHDOG_MS } from "../../daemon/abort-watchdog.js";
75
+ import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../../plugin-api/constants.js";
75
76
  import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
76
77
  import {
77
78
  CALL_OPENING_MARKER,
@@ -125,7 +126,7 @@ interface FakeConversation {
125
126
  metadata?: Record<string, unknown>;
126
127
  }) => Promise<{ id: string }>;
127
128
  workingDir: string;
128
- updateClient: (cb: unknown, reset?: boolean) => void;
129
+ addEventObserver: (observer: unknown) => () => void;
129
130
  handleConfirmationResponse: (
130
131
  requestId: string,
131
132
  decision: string,
@@ -191,13 +192,15 @@ function makeFakeConversation(opts: {
191
192
  opts.onPersist?.(persistCount);
192
193
  return { id: `msg-${persistCount}` };
193
194
  },
194
- // The install (reset falsy) / reset (reset true) pair marks a turn
195
- // taking ownership of the conversation vs a turn's cleanup releasing it.
196
- updateClient: (cb, reset) => {
197
- opts.events?.push(reset ? "client:reset" : "client:install");
198
- if (reset !== true) {
199
- clientCallback = cb as (msg: unknown) => Promise<void>;
200
- }
195
+ // The install / reset pair marks a turn taking ownership of the
196
+ // conversation (observer registered) vs a turn's cleanup releasing it
197
+ // (the returned disposer invoked).
198
+ addEventObserver: (observer) => {
199
+ opts.events?.push("client:install");
200
+ clientCallback = observer as (msg: unknown) => Promise<void>;
201
+ return () => {
202
+ opts.events?.push("client:reset");
203
+ };
201
204
  },
202
205
  handleConfirmationResponse: (requestId, decision) => {
203
206
  confirmationDecisions.push({ requestId, decision });
@@ -213,7 +216,7 @@ function makeFakeConversation(opts: {
213
216
  conversation,
214
217
  waitForIdleCalls,
215
218
  confirmationDecisions,
216
- /** Deliver an event the way the conversation would, to the installed handler. */
219
+ /** Deliver an event the way the conversation would, to the installed observer. */
217
220
  emitToClient: async (msg: unknown) => {
218
221
  await clientCallback?.(msg);
219
222
  },
@@ -360,6 +363,10 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
360
363
  // not merely echo-suppressed live.
361
364
  const fake = makeFakeConversation({ processing: false });
362
365
  fakeConversation = fake.conversation;
366
+ let runOptions: Record<string, unknown> | undefined;
367
+ fake.conversation.runAgentLoop = async (...args: unknown[]) => {
368
+ runOptions = args[2] as Record<string, unknown>;
369
+ };
363
370
 
364
371
  await startVoiceTurn({
365
372
  ...makeTurnOptions(),
@@ -372,7 +379,11 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
372
379
  expect(fake.lastPersistOpts()?.metadata).toEqual({
373
380
  voiceSessionTurn: true,
374
381
  hidden: true,
382
+ messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
375
383
  });
384
+ expect(runOptions?.messageKind).toBe(
385
+ VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
386
+ );
376
387
  });
377
388
 
378
389
  test("the opener prompt is persisted un-hidden (unchanged)", async () => {
@@ -967,7 +978,7 @@ describe("startVoiceTurn prior-turn teardown barrier", () => {
967
978
  expect(events).toEqual(["persist", "client:install"]);
968
979
 
969
980
  // Turn 2 arrives during the teardown window: it must not persist or
970
- // install its client callback until turn 1's cleanup has run.
981
+ // install its event observer until turn 1's cleanup has run.
971
982
  const turn2 = startVoiceTurn(
972
983
  makeTurnOptions(undefined, "conv-teardown-order"),
973
984
  );
@@ -85,6 +85,14 @@ describe("front-door decision rule", () => {
85
85
  expect(rule.toLowerCase()).toContain("tool");
86
86
  });
87
87
 
88
+ test("escalates when an answer needs personal memory absent from context", () => {
89
+ expect(rule.toLowerCase()).toContain("saved personal fact");
90
+ expect(rule.toLowerCase()).toContain("escalate rather than guessing");
91
+ expect(rule.toLowerCase()).toContain(
92
+ "personal context that is already present is yours to use directly",
93
+ );
94
+ });
95
+
88
96
  test("demands a silent decision — no narrated reasoning in spoken output", () => {
89
97
  // Regression: a weak front-door model narrated its triage deliberation
90
98
  // aloud ("Context is complete — Alex paused...") before the bridge.
@@ -730,8 +730,10 @@ export class CallController {
730
730
  let ttsBuffer = "";
731
731
  let fullResponseText = "";
732
732
  // Reasoning models can inline <think> spans in the content stream when a
733
- // profile has not opted into parseThinkTags; the spoken path must never
734
- // read them aloud. fullResponseText keeps the raw stream.
733
+ // profile has not opted into parseThinkTags. Neither the spoken path nor
734
+ // the post-turn consumers of fullResponseText (transcripts,
735
+ // assistant_spoke, END_CALL/ASK_GUARDIAN detection) may see them: both
736
+ // are fed only filtered text.
735
737
  const reasoningFilter = createReasoningTagFilter();
736
738
 
737
739
  // Synthesized path: text is split at speakable boundaries as it streams
@@ -935,8 +937,13 @@ export class CallController {
935
937
  if (!this.isCurrentRun(runVersion)) {
936
938
  return;
937
939
  }
938
- fullResponseText += text;
939
- ttsBuffer += reasoningFilter.push(text);
940
+ // One filter feeds both consumers: the spoken stream and the text
941
+ // used for transcripts, assistant_spoke, and END_CALL/ASK_GUARDIAN
942
+ // marker detection. A control marker inside a reasoning span must
943
+ // never trigger a real action the caller did not hear.
944
+ const speakable = reasoningFilter.push(text);
945
+ fullResponseText += speakable;
946
+ ttsBuffer += speakable;
940
947
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
941
948
  flushSafeText();
942
949
  };
@@ -1015,9 +1022,11 @@ export class CallController {
1015
1022
  return fullResponseText;
1016
1023
  }
1017
1024
 
1018
- // Final sweep: release any held-back partial tag, then strip any
1019
- // remaining control markers from the buffer.
1020
- ttsBuffer += reasoningFilter.flush();
1025
+ // Final sweep: release any held-back partial tag to both consumers,
1026
+ // then strip any remaining control markers from the buffer.
1027
+ const filterTail = reasoningFilter.flush();
1028
+ fullResponseText += filterTail;
1029
+ ttsBuffer += filterTail;
1021
1030
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
1022
1031
  if (ttsBuffer.length > 0) {
1023
1032
  emitSafeChunk(ttsBuffer);
@@ -34,6 +34,7 @@ import {
34
34
  recordConversationPersistedSeq,
35
35
  updateMessageContent,
36
36
  } from "../persistence/conversation-crud.js";
37
+ import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../plugin-api/constants.js";
37
38
  import { pinnedListeningLanguage } from "../providers/speech-to-text/provider-catalog.js";
38
39
  import type { ContentBlock } from "../providers/types.js";
39
40
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
@@ -783,11 +784,13 @@ export async function startVoiceTurn(
783
784
  // model wakes, but they are not user speech and must not render as a live
784
785
  // user bubble. Their echo is suppressed below (parity with
785
786
  // `isEchoSuppressedUserMessage` on the text path).
787
+ const isEscalationContinuation =
788
+ opts.content === ESCALATION_CONTINUATION_CONTENT;
786
789
  const isSyntheticVoicePrompt =
787
790
  opts.hiddenSyntheticPrompt === true ||
788
791
  opts.content === CALL_OPENING_MARKER ||
789
792
  opts.content === CALL_VERIFICATION_COMPLETE_MARKER ||
790
- opts.content === ESCALATION_CONTINUATION_CONTENT;
793
+ isEscalationContinuation;
791
794
 
792
795
  // The escalation-continuation prompt is a pure internal instruction ("give
793
796
  // the full answer now"), not a real utterance and not the sort of scaffolding
@@ -799,8 +802,7 @@ export async function startVoiceTurn(
799
802
  // affects client display. A caller whose prompt text is not a fixed sentinel
800
803
  // opts into the same treatment with `hiddenSyntheticPrompt`.
801
804
  const isHiddenSyntheticPrompt =
802
- opts.hiddenSyntheticPrompt === true ||
803
- opts.content === ESCALATION_CONTINUATION_CONTENT;
805
+ opts.hiddenSyntheticPrompt === true || isEscalationContinuation;
804
806
 
805
807
  // Build the call-control protocol prompt so the model knows how to emit
806
808
  // control markers (ASK_GUARDIAN, END_CALL, etc.) and recognize opener turns.
@@ -934,10 +936,10 @@ export async function startVoiceTurn(
934
936
  // failed while no concurrent turn held the lock). Paths where this turn
935
937
  // LOST the conversation to a concurrent winner must use `restoreTurnState`
936
938
  // instead — this reset-to-defaults would clobber the winner's live state.
937
- // The client callback is only reset when this turn actually installed it
938
- // (tracked via `clientCallbackInstalled`); otherwise cleanup would detach
939
- // an active sender installed by a prior turn.
940
- let clientCallbackInstalled = false;
939
+ // The approval observer is only detached when this turn installed it
940
+ // (`detachApprovalObserver` is set); otherwise cleanup would detach an
941
+ // observer installed by a prior turn.
942
+ let detachApprovalObserver: (() => void) | undefined;
941
943
  /**
942
944
  * Confirmations this voice turn left pending for the user to answer, and the
943
945
  * timers that stop them waiting forever.
@@ -976,11 +978,10 @@ export async function startVoiceTurn(
976
978
  conversation.setVoiceCallControlPrompt(null);
977
979
  conversation.callSessionId = undefined;
978
980
  conversation.forcePromptSideEffects = false;
979
- if (clientCallbackInstalled) {
980
- // Reset the client callback to a no-op so the stale closure doesn't
981
- // intercept events from future turns on the same conversation.
982
- conversation.updateClient(() => {}, true);
983
- }
981
+ // Detach so the stale closure doesn't act on events from future turns on
982
+ // the same conversation.
983
+ detachApprovalObserver?.();
984
+ detachApprovalObserver = undefined;
984
985
  };
985
986
 
986
987
  const requestId = uuidv7();
@@ -994,6 +995,9 @@ export async function startVoiceTurn(
994
995
  // `isVoiceSessionUserMessage` for why the channel fields cannot carry it.
995
996
  voiceSessionTurn: true,
996
997
  ...(isHiddenSyntheticPrompt ? { hidden: true } : {}),
998
+ ...(isEscalationContinuation
999
+ ? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
1000
+ : {}),
997
1001
  ...(opts.voiceTelemetry
998
1002
  ? {
999
1003
  // Projected onto `TurnTelemetryEvent.client` by
@@ -1245,24 +1249,25 @@ export async function startVoiceTurn(
1245
1249
  publishConversationMessagesChanged(opts.conversationId);
1246
1250
  }
1247
1251
 
1248
- // Hook into conversation to intercept confirmation_request and secret_request events.
1249
- // Voice auto-denies/auto-allows/auto-resolves these since there's no interactive UI.
1252
+ // Observe confirmation_request and secret_request events so voice can
1253
+ // auto-deny/auto-allow/auto-resolve them (there is no interactive UI on a
1254
+ // call). Delivery is not this observer's job: the conversation's sink has
1255
+ // already put each event on the wire before the observer sees it, which is
1256
+ // what keeps a resolution's `interaction_resolved` after the request it
1257
+ // answers.
1250
1258
  let lastError: string | null = null;
1251
- conversation.updateClient(async (msg: AssistantEvent) => {
1259
+ const handleVoiceEvent = async (msg: AssistantEvent): Promise<void> => {
1252
1260
  // The user (or anything else) answered: stop the fallback from firing on a
1253
1261
  // request that is already decided.
1254
1262
  if (msg.type === "interaction_resolved") {
1255
1263
  settleVoiceApproval(msg.requestId);
1256
1264
  }
1257
1265
  if (msg.type === "confirmation_request") {
1258
- // Broadcast the request BEFORE resolving it: resolution synchronously
1259
- // broadcasts `interaction_resolved` (handleConfirmationResponse →
1260
- // prompter → pending-interactions), and attached clients (e.g. the
1261
- // web app behind a live-voice room) clear their approval card only on
1262
- // that event — resolving first would put `interaction_resolved`
1263
- // before `confirmation_request` on the wire, leaving an orphaned card
1264
- // whose Allow/Deny buttons 404.
1265
- broadcastMessage(msg);
1266
+ // The request is already on the wire (the sink delivered it before this
1267
+ // observer ran), so a resolution below lands after it: attached clients
1268
+ // (e.g. the web app behind a live-voice room) clear their approval card
1269
+ // only on `interaction_resolved`, and the reverse order would leave an
1270
+ // orphaned card whose Allow/Deny buttons 404.
1266
1271
  if (!isGuardian) {
1267
1272
  // Non-guardian voice callers have no interactive approval UI.
1268
1273
  // The pre-exec gate (tool-approval-handler.ts) handles grant
@@ -1420,9 +1425,15 @@ export async function startVoiceTurn(
1420
1425
  conversation.handleSecretResponse(msg.requestId, undefined, "store");
1421
1426
  return;
1422
1427
  }
1423
- broadcastMessage(msg);
1428
+ };
1429
+ detachApprovalObserver = conversation.addEventObserver((msg) => {
1430
+ handleVoiceEvent(msg).catch((err) => {
1431
+ log.warn(
1432
+ { turnId, eventType: msg.type, err },
1433
+ "Voice approval observer failed",
1434
+ );
1435
+ });
1424
1436
  });
1425
- clientCallbackInstalled = true;
1426
1437
 
1427
1438
  // Registered before the agent loop starts so the NEXT turn on this
1428
1439
  // conversation waits for this turn's `finally { cleanup() }` — not just
@@ -1709,11 +1720,19 @@ export async function startVoiceTurn(
1709
1720
  // ordinary call-agent resolution.
1710
1721
  callSite:
1711
1722
  opts.routingLeg === "front-door" ? "voiceFrontDoor" : "callAgent",
1723
+ // A caller is on the line, so the turn is interactive: approval prompts
1724
+ // must be raised rather than pre-denied, because the approval observer
1725
+ // above is what decides them (auto-resolve for a non-guardian caller,
1726
+ // a real card the guardian can answer).
1727
+ isInteractive: true,
1712
1728
  // The escalation-continuation prompt is a transcript-suppressed machine
1713
1729
  // signal (persisted `hidden`), so flag the turn to match — keeps
1714
1730
  // prompt-as-user-speech consumers (e.g. title generation) from treating
1715
1731
  // it as user speech.
1716
1732
  ...(isHiddenSyntheticPrompt ? { isHiddenPrompt: true } : {}),
1733
+ ...(isEscalationContinuation
1734
+ ? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
1735
+ : {}),
1717
1736
  // Triage-and-escalate routing pins this turn to the fast front-door or
1718
1737
  // strong escalation profile. `forceOverrideProfile` floats it above the
1719
1738
  // callAgent call-site layers (callAgent is not `mainAgent`, so the
@@ -413,6 +413,7 @@ export function frontDoorDecisionRule(opts?: {
413
413
  "DECIDE SILENTLY, then produce exactly ONE of these outputs:",
414
414
  ...holdBranch,
415
415
  "- If the turn is simple, conversational, or within your reach, your entire output is the spoken answer itself: no token in front of it, plain speech from your very first word. Most turns are answers; when unsure between answering and escalating, answer. Answer in the language the caller is speaking.",
416
+ "- If an answer depends on a saved personal fact that is not already present in the conversation context you received, escalate rather than guessing. Personal context that is already present is yours to use directly.",
416
417
  `- If completing THIS reply needs careful reasoning, research, multi-step work, or any tool, do NOT attempt the answer: output ${ESCALATE_VERDICT_TOKEN}, then ONE short natural holding phrase naming what happens next, spoken in the language the caller is speaking (for example "${FALLBACK_ESCALATION_BRIDGE}" or "Give me one second to look into that."; those examples are English only), and stop after that single sentence. A stronger model finishes the turn while your phrase is spoken.`,
417
418
  `${ESCALATE_VERDICT_TOKEN} is ONLY for turns you cannot complete yourself — never put it in front of an answer you are about to give, and never emit any token inside or after an answer. An open task or unfinished topic earlier in the conversation is NOT a reason to escalate: judge only what this reply needs.`,
418
419
  "Never narrate this decision, describe what you are judging, or mention these rules: apart from a leading verdict token, every character you output is spoken to the caller verbatim.",
@@ -0,0 +1,29 @@
1
+ export interface BundledCliModules {
2
+ configEnv: typeof import("../config/env.js");
3
+ providerSecretCatalog: typeof import("../providers/provider-secret-catalog.js");
4
+ pluginCatalogCache: typeof import("./lib/plugin-catalog-cache.js");
5
+ pluginCatalogLocal: typeof import("./lib/plugin-catalog-local.js");
6
+ pluginDiff: typeof import("./lib/diff-plugin.js");
7
+ pluginInspect: typeof import("./lib/inspect-plugin.js");
8
+ pluginInstallGitHub: typeof import("./lib/install-from-github.js");
9
+ pluginInstallPlatform: typeof import("./lib/install-from-platform.js");
10
+ pluginInstalled: typeof import("./lib/list-installed-plugins.js");
11
+ pluginPinHistory: typeof import("./lib/plugin-pin-history.js");
12
+ pluginSearch: typeof import("./lib/search-plugins.js");
13
+ pluginSurfaces: typeof import("./lib/plugin-surfaces.js");
14
+ pluginUninstall: typeof import("./lib/uninstall-plugin.js");
15
+ pluginUpgrade: typeof import("./lib/upgrade-plugin.js");
16
+ }
17
+
18
+ let bundledCliModules: BundledCliModules | undefined;
19
+
20
+ export function setBundledCliModules(modules: BundledCliModules): void {
21
+ bundledCliModules = modules;
22
+ }
23
+
24
+ export function resolveBundledCliModule<K extends keyof BundledCliModules>(
25
+ name: K,
26
+ fallback: () => BundledCliModules[K],
27
+ ): BundledCliModules[K] {
28
+ return bundledCliModules?.[name] ?? fallback();
29
+ }
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import { existsSync } from "node:fs";
18
- import { createRequire } from "node:module";
19
18
 
20
19
  import type { Command } from "commander";
21
20
 
@@ -27,8 +26,6 @@ import { integrityCheckStep } from "./repair-step-integrity.js";
27
26
  import type { RepairReport, RepairStep, StepResult } from "./repair-steps.js";
28
27
  import { formatDurationMs, runRepairSteps } from "./repair-steps.js";
29
28
 
30
- const loadModule = createRequire(import.meta.url);
31
-
32
29
  // ---------------------------------------------------------------------------
33
30
  // Step sequence
34
31
  // ---------------------------------------------------------------------------
@@ -38,10 +35,9 @@ const loadModule = createRequire(import.meta.url);
38
35
  * structural damage surfaces early. Resolved lazily because the backfill
39
36
  * step pulls the DB graph.
40
37
  */
41
- function repairSteps(): RepairStep[] {
42
- const { conversationBackfillStep } = loadModule(
43
- "./repair-step-conversation-backfill.js",
44
- ) as typeof import("./repair-step-conversation-backfill.js");
38
+ async function repairSteps(): Promise<RepairStep[]> {
39
+ const { conversationBackfillStep } =
40
+ await import("./repair-step-conversation-backfill.js");
45
41
  return [integrityCheckStep, conversationBackfillStep];
46
42
  }
47
43
 
@@ -128,7 +124,7 @@ export function registerDbRepair(parent: Command): void {
128
124
 
129
125
  const report = await runRepairSteps(
130
126
  { dbPath },
131
- repairSteps(),
127
+ await repairSteps(),
132
128
  isJson
133
129
  ? {}
134
130
  : {
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import type { Command } from "commander";
4
4
 
5
5
  import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
6
+ import { resolveBundledCliModule } from "../bundled-modules.js";
6
7
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
7
8
  import { registerCommand } from "../lib/register-command.js";
8
9
  import { getCliLogger } from "../logger.js";
@@ -19,9 +20,11 @@ const log = getCliLogger("domain");
19
20
  * synchronously, so there is no place to await a module load.
20
21
  */
21
22
  function envDomains(): { apexDomain: string; baseDomain: string } {
22
- const { getApexDomain, getAssistantDomain } = loadModule(
23
- "../../config/env.js",
24
- ) as typeof import("../../config/env.js");
23
+ const { getApexDomain, getAssistantDomain } = resolveBundledCliModule(
24
+ "configEnv",
25
+ () =>
26
+ loadModule("../../config/env.js") as typeof import("../../config/env.js"),
27
+ );
25
28
  return { apexDomain: getApexDomain(), baseDomain: getAssistantDomain() };
26
29
  }
27
30
 
@@ -16,6 +16,7 @@ import {
16
16
  } from "../../ipc/cli-client.js";
17
17
  import { guessMimeType } from "../../util/mime-type.js";
18
18
  import { readStdinSync } from "../../util/read-stdin.js";
19
+ import { resolveBundledCliModule } from "../bundled-modules.js";
19
20
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
20
21
  import { registerCommand } from "../lib/register-command.js";
21
22
  import { getCliLogger } from "../logger.js";
@@ -60,9 +61,11 @@ function handleEmailIpcError(
60
61
  * synchronously, so there is no place to await a module load.
61
62
  */
62
63
  function assistantDomain(): string {
63
- const { getAssistantDomain } = loadModule(
64
- "../../config/env.js",
65
- ) as typeof import("../../config/env.js");
64
+ const { getAssistantDomain } = resolveBundledCliModule(
65
+ "configEnv",
66
+ () =>
67
+ loadModule("../../config/env.js") as typeof import("../../config/env.js"),
68
+ );
66
69
  return getAssistantDomain();
67
70
  }
68
71
 
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
 
3
3
  import type { Command } from "commander";
4
4
 
5
+ import { resolveBundledCliModule } from "../bundled-modules.js";
5
6
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
6
7
  import { registerCommand } from "../lib/register-command.js";
7
8
  import { log } from "../logger.js";
@@ -16,9 +17,13 @@ const loadModule = createRequire(import.meta.url);
16
17
  * callbacks synchronously, so there is no place to await a module load.
17
18
  */
18
19
  function apiKeyProviders(): readonly string[] {
19
- const { API_KEY_PROVIDERS } = loadModule(
20
- "../../providers/provider-secret-catalog.js",
21
- ) as typeof import("../../providers/provider-secret-catalog.js");
20
+ const { API_KEY_PROVIDERS } = resolveBundledCliModule(
21
+ "providerSecretCatalog",
22
+ () =>
23
+ loadModule(
24
+ "../../providers/provider-secret-catalog.js",
25
+ ) as typeof import("../../providers/provider-secret-catalog.js"),
26
+ );
22
27
  return API_KEY_PROVIDERS;
23
28
  }
24
29
 
@@ -15,6 +15,7 @@ import { cliIpcCall } from "../../ipc/cli-client.js";
15
15
  import { stripAnsiAndControlChars } from "../../util/ansi.js";
16
16
  import { getWorkspacePluginsDir } from "../../util/platform.js";
17
17
  import { truncate } from "../../util/truncate.js";
18
+ import { resolveBundledCliModule } from "../bundled-modules.js";
18
19
  import { yellow } from "../lib/cli-colors.js";
19
20
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
20
21
  import { confirmPrompt } from "../lib/confirm-prompt.js";
@@ -61,64 +62,112 @@ const loadModule = createRequire(import.meta.url);
61
62
  /** Lazy accessors: each getter loads its implementation module on first use. */
62
63
  const libs = {
63
64
  get catalogCache() {
64
- return loadModule(
65
- "../lib/plugin-catalog-cache.js",
66
- ) as typeof import("../lib/plugin-catalog-cache.js");
65
+ return resolveBundledCliModule(
66
+ "pluginCatalogCache",
67
+ () =>
68
+ loadModule(
69
+ "../lib/plugin-catalog-cache.js",
70
+ ) as typeof import("../lib/plugin-catalog-cache.js"),
71
+ );
67
72
  },
68
73
  get catalogLocal() {
69
- return loadModule(
70
- "../lib/plugin-catalog-local.js",
71
- ) as typeof import("../lib/plugin-catalog-local.js");
74
+ return resolveBundledCliModule(
75
+ "pluginCatalogLocal",
76
+ () =>
77
+ loadModule(
78
+ "../lib/plugin-catalog-local.js",
79
+ ) as typeof import("../lib/plugin-catalog-local.js"),
80
+ );
72
81
  },
73
82
  get diff() {
74
- return loadModule(
75
- "../lib/diff-plugin.js",
76
- ) as typeof import("../lib/diff-plugin.js");
83
+ return resolveBundledCliModule(
84
+ "pluginDiff",
85
+ () =>
86
+ loadModule(
87
+ "../lib/diff-plugin.js",
88
+ ) as typeof import("../lib/diff-plugin.js"),
89
+ );
77
90
  },
78
91
  get inspect() {
79
- return loadModule(
80
- "../lib/inspect-plugin.js",
81
- ) as typeof import("../lib/inspect-plugin.js");
92
+ return resolveBundledCliModule(
93
+ "pluginInspect",
94
+ () =>
95
+ loadModule(
96
+ "../lib/inspect-plugin.js",
97
+ ) as typeof import("../lib/inspect-plugin.js"),
98
+ );
82
99
  },
83
100
  get installGitHub() {
84
- return loadModule(
85
- "../lib/install-from-github.js",
86
- ) as typeof import("../lib/install-from-github.js");
101
+ return resolveBundledCliModule(
102
+ "pluginInstallGitHub",
103
+ () =>
104
+ loadModule(
105
+ "../lib/install-from-github.js",
106
+ ) as typeof import("../lib/install-from-github.js"),
107
+ );
87
108
  },
88
109
  get installPlatform() {
89
- return loadModule(
90
- "../lib/install-from-platform.js",
91
- ) as typeof import("../lib/install-from-platform.js");
110
+ return resolveBundledCliModule(
111
+ "pluginInstallPlatform",
112
+ () =>
113
+ loadModule(
114
+ "../lib/install-from-platform.js",
115
+ ) as typeof import("../lib/install-from-platform.js"),
116
+ );
92
117
  },
93
118
  get installed() {
94
- return loadModule(
95
- "../lib/list-installed-plugins.js",
96
- ) as typeof import("../lib/list-installed-plugins.js");
119
+ return resolveBundledCliModule(
120
+ "pluginInstalled",
121
+ () =>
122
+ loadModule(
123
+ "../lib/list-installed-plugins.js",
124
+ ) as typeof import("../lib/list-installed-plugins.js"),
125
+ );
97
126
  },
98
127
  get pinHistory() {
99
- return loadModule(
100
- "../lib/plugin-pin-history.js",
101
- ) as typeof import("../lib/plugin-pin-history.js");
128
+ return resolveBundledCliModule(
129
+ "pluginPinHistory",
130
+ () =>
131
+ loadModule(
132
+ "../lib/plugin-pin-history.js",
133
+ ) as typeof import("../lib/plugin-pin-history.js"),
134
+ );
102
135
  },
103
136
  get search() {
104
- return loadModule(
105
- "../lib/search-plugins.js",
106
- ) as typeof import("../lib/search-plugins.js");
137
+ return resolveBundledCliModule(
138
+ "pluginSearch",
139
+ () =>
140
+ loadModule(
141
+ "../lib/search-plugins.js",
142
+ ) as typeof import("../lib/search-plugins.js"),
143
+ );
107
144
  },
108
145
  get surfaces() {
109
- return loadModule(
110
- "../lib/plugin-surfaces.js",
111
- ) as typeof import("../lib/plugin-surfaces.js");
146
+ return resolveBundledCliModule(
147
+ "pluginSurfaces",
148
+ () =>
149
+ loadModule(
150
+ "../lib/plugin-surfaces.js",
151
+ ) as typeof import("../lib/plugin-surfaces.js"),
152
+ );
112
153
  },
113
154
  get uninstall() {
114
- return loadModule(
115
- "../lib/uninstall-plugin.js",
116
- ) as typeof import("../lib/uninstall-plugin.js");
155
+ return resolveBundledCliModule(
156
+ "pluginUninstall",
157
+ () =>
158
+ loadModule(
159
+ "../lib/uninstall-plugin.js",
160
+ ) as typeof import("../lib/uninstall-plugin.js"),
161
+ );
117
162
  },
118
163
  get upgrade() {
119
- return loadModule(
120
- "../lib/upgrade-plugin.js",
121
- ) as typeof import("../lib/upgrade-plugin.js");
164
+ return resolveBundledCliModule(
165
+ "pluginUpgrade",
166
+ () =>
167
+ loadModule(
168
+ "../lib/upgrade-plugin.js",
169
+ ) as typeof import("../lib/upgrade-plugin.js"),
170
+ );
122
171
  },
123
172
  };
124
173