@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.4

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 (209) 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/docs/guardian-request-flow.md +35 -0
  7. package/knip.json +3 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  10. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  11. package/openapi.yaml +136 -76
  12. package/package.json +1 -1
  13. package/scripts/write-plugin-api-shim.ts +10 -0
  14. package/src/__tests__/app-control-flow.test.ts +1 -0
  15. package/src/__tests__/approval-routes-http.test.ts +2 -2
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  17. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  18. package/src/__tests__/compaction-events.test.ts +8 -10
  19. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  21. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  22. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  23. package/src/__tests__/conversation-queue.test.ts +39 -62
  24. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  27. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  28. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
  30. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  31. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  37. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  38. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  39. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  40. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  41. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  42. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  43. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  44. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  45. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  46. package/src/__tests__/document-sync-tags.test.ts +0 -75
  47. package/src/__tests__/file-ops-service.test.ts +163 -30
  48. package/src/__tests__/filesystem-tools.test.ts +23 -24
  49. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  50. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  51. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  52. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  53. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  54. package/src/__tests__/media-generate-image.test.ts +131 -21
  55. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  56. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  57. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  58. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  59. package/src/__tests__/qdrant-manager.test.ts +14 -1
  60. package/src/__tests__/run-due-schedules.test.ts +21 -0
  61. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  62. package/src/__tests__/schedule-routes.test.ts +23 -0
  63. package/src/__tests__/schedule-store.test.ts +17 -0
  64. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  65. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  66. package/src/__tests__/starter-task-flow.test.ts +5 -4
  67. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  68. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  69. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  70. package/src/__tests__/subagent-tools.test.ts +81 -101
  71. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  72. package/src/__tests__/tool-executor.test.ts +5 -1
  73. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  74. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  75. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  76. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  77. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  78. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  79. package/src/acp/session-manager.ts +8 -1
  80. package/src/api/events/host-file.ts +2 -2
  81. package/src/api/surfaces.ts +5 -0
  82. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  83. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  84. package/src/calls/voice-session-bridge.ts +44 -25
  85. package/src/calls/voice-triage-escalate.ts +1 -0
  86. package/src/cli/bundled-modules.ts +29 -0
  87. package/src/cli/commands/db/repair.ts +4 -8
  88. package/src/cli/commands/domain.ts +6 -3
  89. package/src/cli/commands/email.ts +6 -3
  90. package/src/cli/commands/keys.ts +8 -3
  91. package/src/cli/commands/plugins.ts +85 -36
  92. package/src/cli/commands/schedules.ts +35 -1
  93. package/src/cli/lib/bundled-marketplace.json +1 -1
  94. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  95. package/src/config/assistant-feature-flags.ts +36 -15
  96. package/src/config/balanced-model-experiment.ts +35 -0
  97. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  98. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  99. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  100. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  101. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  102. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  103. package/src/config/call-site-defaults.ts +7 -0
  104. package/src/config/default-profile-catalog.ts +96 -4
  105. package/src/config/feature-flag-registry.json +11 -11
  106. package/src/config/skills.ts +9 -2
  107. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  108. package/src/daemon/conversation-agent-loop.ts +14 -13
  109. package/src/daemon/conversation-notifiers.ts +11 -9
  110. package/src/daemon/conversation-process.ts +0 -27
  111. package/src/daemon/conversation-runtime-assembly.ts +9 -2
  112. package/src/daemon/conversation-store.ts +4 -4
  113. package/src/daemon/conversation-surfaces.ts +27 -13
  114. package/src/daemon/conversation-tool-setup.ts +2 -4
  115. package/src/daemon/conversation.ts +104 -51
  116. package/src/daemon/doordash-steps.ts +2 -2
  117. package/src/daemon/lifecycle.ts +14 -1
  118. package/src/daemon/process-message.ts +0 -13
  119. package/src/daemon/windows-compiled-entry.ts +4 -0
  120. package/src/documents/document-store.ts +5 -235
  121. package/src/hooks/types.ts +5 -0
  122. package/src/ipc/gateway-flag-listener.ts +17 -3
  123. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  124. package/src/live-voice/live-voice-manager.ts +16 -3
  125. package/src/live-voice/live-voice-session.ts +63 -9
  126. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  127. package/src/monitoring/control.ts +1 -0
  128. package/src/monitoring/db-integrity-sample.ts +4 -5
  129. package/src/notifications/AGENTS.md +2 -0
  130. package/src/notifications/approval-card-data.ts +33 -0
  131. package/src/permissions/prompter.ts +1 -5
  132. package/src/persistence/conversation-queries.ts +66 -16
  133. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  134. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  135. package/src/persistence/schema/documents.ts +4 -4
  136. package/src/plugin-api/constants.ts +8 -0
  137. package/src/plugin-api/index.ts +5 -1
  138. package/src/plugin-api/webhook-url.ts +13 -11
  139. package/src/plugins/defaults/main.ts +6 -7
  140. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  141. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  142. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  143. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  144. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  145. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  146. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  147. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  148. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  149. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  150. package/src/plugins/mtime-cache.ts +17 -0
  151. package/src/prompts/templates/system-sections.ts +0 -7
  152. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  153. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  154. package/src/providers/openai/chat-completions-provider.ts +11 -1
  155. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  156. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  157. package/src/routes/control.ts +1 -0
  158. package/src/routes/route-host-client.ts +1 -0
  159. package/src/runtime/AGENTS.md +16 -17
  160. package/src/runtime/agent-wake.ts +15 -12
  161. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  162. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  163. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  164. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  165. package/src/runtime/routes/conversation-routes.ts +11 -13
  166. package/src/runtime/routes/documents-routes.ts +3 -222
  167. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  168. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  169. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  170. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  171. package/src/runtime/routes/schedule-routes.ts +94 -7
  172. package/src/runtime/routes/workspace-routes.ts +0 -9
  173. package/src/runtime/routes/workspace-utils.ts +3 -13
  174. package/src/runtime/services/conversation-serializer.ts +7 -2
  175. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  176. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  177. package/src/schedule/plugin-schedule-availability.ts +58 -0
  178. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  179. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  180. package/src/schedule/schedule-store.ts +5 -1
  181. package/src/schedule/scheduler.ts +9 -4
  182. package/src/schedule/worker-control.ts +1 -0
  183. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  184. package/src/subagent/consult-context.ts +11 -11
  185. package/src/subagent/consult-prompt.ts +26 -35
  186. package/src/subagent/manager.ts +20 -37
  187. package/src/subagent/notify.ts +7 -1
  188. package/src/subagent/types.ts +15 -13
  189. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  190. package/src/tools/acp/spawn.ts +6 -4
  191. package/src/tools/filesystem/read.ts +27 -10
  192. package/src/tools/host-filesystem/read.ts +27 -15
  193. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  194. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  195. package/src/tools/shared/filesystem/types.ts +5 -5
  196. package/src/tools/skills/scaffold-managed.ts +25 -7
  197. package/src/tools/subagent/spawn.ts +28 -88
  198. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  199. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  200. package/src/util/logger.ts +16 -0
  201. package/src/util/worker-process.ts +37 -4
  202. package/src/windows-compiled-cli.ts +32 -0
  203. package/src/windows-compiled-entry.ts +4 -0
  204. package/src/windows-compiled-logger.ts +6 -0
  205. package/src/windows-compiled-worker-entry.ts +29 -0
  206. package/src/__tests__/document-workspace-file.test.ts +0 -467
  207. package/src/daemon/interactive-turn-sender.ts +0 -59
  208. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  209. package/src/subagent/consult-transcript.ts +0 -90
@@ -4,6 +4,7 @@ import { reconcileCallsOnStartup } from "../calls/call-recovery.js";
4
4
  import { TwilioVoiceProvider } from "../calls/twilio-provider.js";
5
5
  import { expireInteractionBoundGuardianRequests } from "../channels/gateway-guardian-requests.js";
6
6
  import { initFeatureFlagOverrides } from "../config/assistant-feature-flags.js";
7
+ import { getBalancedModelExperimentArm } from "../config/balanced-model-experiment.js";
7
8
  import { setIngressPublicBaseUrl, validateEnv } from "../config/env.js";
8
9
  import {
9
10
  hasPendingDefaultWorkspaceConfig,
@@ -224,9 +225,21 @@ export async function runDaemon(): Promise<void> {
224
225
  // a failed fetch leaves the cache unset and resolves `os-beta` to its
225
226
  // registry default `false`, which would remove the user's profile and reset
226
227
  // their selection.
228
+ // A balanced-model experiment arm arriving in this same load gets the same
229
+ // invalidation. HTTP binds before this resolves, so a client that fetched
230
+ // profiles in that window holds the shipped model; the arm moves nothing on
231
+ // disk, so the reconcile above would not report a change and the listener's
232
+ // own comparison sees the arm on both sides of its refresh.
233
+ const balancedArmBeforeInit = getBalancedModelExperimentArm();
227
234
  void initFeatureFlagOverrides()
228
235
  .then((loaded) => {
229
- if (loaded && reconcileFlagGatedProfiles()) {
236
+ if (!loaded) {
237
+ return;
238
+ }
239
+ const profilesChanged = reconcileFlagGatedProfiles();
240
+ const balancedArmChanged =
241
+ getBalancedModelExperimentArm() !== balancedArmBeforeInit;
242
+ if (profilesChanged || balancedArmChanged) {
230
243
  publishConfigChanged();
231
244
  }
232
245
  })
@@ -69,7 +69,6 @@ import {
69
69
  preactivateHostProxySkills,
70
70
  shouldAttachHostProxyForCapability,
71
71
  } from "./host-proxy-preactivation.js";
72
- import { bindInteractiveTurnSender } from "./interactive-turn-sender.js";
73
72
  import type { SubagentToolGateMode } from "./tool-setup-types.js";
74
73
  import { restingTrust } from "./trust-context-types.js";
75
74
 
@@ -709,11 +708,6 @@ export async function processMessage(
709
708
  };
710
709
  }
711
710
 
712
- const restoreSender =
713
- options?.isInteractive === true
714
- ? bindInteractiveTurnSender(conversation)
715
- : undefined;
716
-
717
711
  const restoreToolScope = applyTurnToolAllowlist(conversation, options);
718
712
  try {
719
713
  await conversation.runAgentLoop(resolvedContent, messageId, {
@@ -731,7 +725,6 @@ export async function processMessage(
731
725
  });
732
726
  } finally {
733
727
  restoreToolScope();
734
- restoreSender?.();
735
728
  }
736
729
 
737
730
  // Read the just-finished turn's outcome from the stamp `runAgentLoop`'s
@@ -790,11 +783,6 @@ export async function processMessageInBackground(
790
783
  return { messageId };
791
784
  }
792
785
 
793
- const restoreSender =
794
- options?.isInteractive === true
795
- ? bindInteractiveTurnSender(conversation)
796
- : undefined;
797
-
798
786
  const restoreToolScope = applyTurnToolAllowlist(conversation, options);
799
787
  conversation
800
788
  .runAgentLoop(content, messageId, {
@@ -809,7 +797,6 @@ export async function processMessageInBackground(
809
797
  })
810
798
  .finally(() => {
811
799
  restoreToolScope();
812
- restoreSender?.();
813
800
  })
814
801
  .catch((err) => {
815
802
  log.error({ err, conversationId }, "Background agent loop failed");
@@ -0,0 +1,4 @@
1
+ import "../windows-compiled-logger.js";
2
+ import "../live-voice/windows-compiled-live-voice.js";
3
+
4
+ await import("./main.js");
@@ -6,7 +6,6 @@
6
6
  * without going through the HTTP layer.
7
7
  */
8
8
  import { rawAll, rawGet, rawRun } from "../persistence/raw-query.js";
9
- import { writeWorkspaceFile } from "../runtime/routes/workspace-utils.js";
10
9
  import { getLogger } from "../util/logger.js";
11
10
 
12
11
  const log = getLogger("document-store");
@@ -24,18 +23,13 @@ export interface DocumentRecord {
24
23
  wordCount: number;
25
24
  createdAt: number;
26
25
  updatedAt: number;
27
- /**
28
- * Workspace-relative path of the markdown file this document is bound to,
29
- * or `null` when the document has no file behind it.
30
- */
31
- workspacePath: string | null;
32
26
  }
33
27
 
34
28
  /**
35
- * The listing projection: neither the body nor the file binding, which the
36
- * list and search queries deliberately leave out of their SELECTs.
29
+ * The listing projection: everything but the body, which the list and search
30
+ * queries deliberately leave out of their SELECTs.
37
31
  */
38
- export type DocumentSummary = Omit<DocumentRecord, "content" | "workspacePath">;
32
+ export type DocumentSummary = Omit<DocumentRecord, "content">;
39
33
 
40
34
  /** Words in a markdown body, the count the `word_count` column stores. */
41
35
  function countWords(content: string): number {
@@ -72,10 +66,9 @@ interface DocumentRow {
72
66
  word_count: number;
73
67
  created_at: number;
74
68
  updated_at: number;
75
- workspace_path: string | null;
76
69
  }
77
70
 
78
- type DocumentListRow = Omit<DocumentRow, "content" | "workspace_path">;
71
+ type DocumentListRow = Omit<DocumentRow, "content">;
79
72
 
80
73
  function escapeSqlLikePattern(value: string): string {
81
74
  return value.replace(/[\\%_]/g, "\\$&");
@@ -90,7 +83,6 @@ function mapRowToRecord(row: DocumentRow): DocumentRecord {
90
83
  wordCount: row.word_count,
91
84
  createdAt: row.created_at,
92
85
  updatedAt: row.updated_at,
93
- workspacePath: row.workspace_path,
94
86
  };
95
87
  }
96
88
 
@@ -99,7 +91,7 @@ export function getDocumentById(surfaceId: string): DocumentRecord | null {
99
91
  try {
100
92
  const row = rawGet<DocumentRow>(
101
93
  "documents:getDocumentById",
102
- /*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path
94
+ /*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at
103
95
  FROM documents
104
96
  WHERE surface_id = ?`,
105
97
  surfaceId,
@@ -118,29 +110,6 @@ export function getDocumentById(surfaceId: string): DocumentRecord | null {
118
110
  }
119
111
  }
120
112
 
121
- /**
122
- * Look up the document bound to a workspace file. `workspacePath` must be the
123
- * normalized workspace-relative path stored on the row. Returns `null` when no
124
- * document is bound to that file.
125
- */
126
- export function getDocumentByWorkspacePath(
127
- workspacePath: string,
128
- ): DocumentRecord | null {
129
- try {
130
- const row = rawGet<DocumentRow>(
131
- "documents:getDocumentByWorkspacePath",
132
- /*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path
133
- FROM documents
134
- WHERE workspace_path = ?`,
135
- workspacePath,
136
- );
137
- return row ? mapRowToRecord(row) : null;
138
- } catch (error) {
139
- log.error({ err: error, workspacePath }, "Load-by-workspace-path error");
140
- return null;
141
- }
142
- }
143
-
144
113
  /** Return true when a document is associated with a conversation. */
145
114
  export function isDocumentAssociatedWithConversation(
146
115
  surfaceId: string,
@@ -429,202 +398,6 @@ export function findInDocument(
429
398
  }
430
399
  }
431
400
 
432
- // ---------------------------------------------------------------------------
433
- // Workspace file binding
434
- // ---------------------------------------------------------------------------
435
-
436
- /**
437
- * Mirror `content` onto the workspace file a document is bound to.
438
- *
439
- * This is the single write-through choke point: every path that persists
440
- * document content — {@link saveDocument} (client save and `document_create`),
441
- * {@link updateDocumentContent} (`document_update`), and
442
- * {@link replaceInDocument} (`document_replace_text`) — calls it immediately
443
- * before its own row write, so a file-backed document's markdown on disk always
444
- * matches the stored row.
445
- *
446
- * The file is written first and a failure throws: a rejected write aborts the
447
- * update before the row changes rather than leaving the two silently diverged.
448
- * Documents with no file behind them are a no-op.
449
- */
450
- function writeThroughToWorkspaceFile(surfaceId: string, content: string): void {
451
- const row = rawGet<{ workspace_path: string | null }>(
452
- "documents:writeThroughToWorkspaceFile:getPath",
453
- /*sql*/ `SELECT workspace_path FROM documents WHERE surface_id = ?`,
454
- surfaceId,
455
- );
456
- const workspacePath = row?.workspace_path;
457
- if (!workspacePath) {
458
- return;
459
- }
460
-
461
- try {
462
- writeWorkspaceFile(workspacePath, Buffer.from(content, "utf-8"));
463
- log.info({ surfaceId, workspacePath }, "Wrote document through to file");
464
- } catch (error) {
465
- const message = error instanceof Error ? error.message : String(error);
466
- throw new Error(
467
- `Failed to write file-backed document to ${workspacePath}: ${message}`,
468
- );
469
- }
470
- }
471
-
472
- /**
473
- * Create a document seeded from a workspace markdown file and bind it to that
474
- * file. The returned surface ID is the file's document identity — comments,
475
- * assistant iteration, and PDF export all key off it.
476
- *
477
- * The partial unique index on `workspace_path` makes a concurrent second
478
- * create fail rather than produce a duplicate binding; callers re-read by path
479
- * on failure.
480
- */
481
- export function createFileBackedDocument(params: {
482
- surfaceId: string;
483
- conversationId: string;
484
- title: string;
485
- content: string;
486
- workspacePath: string;
487
- }): { success: true } | { success: false; error: string } {
488
- try {
489
- const now = Date.now();
490
- rawRun(
491
- "documents:createFileBackedDocument",
492
- /*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path)
493
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
494
- params.surfaceId,
495
- params.conversationId,
496
- params.title,
497
- params.content,
498
- countWords(params.content),
499
- now,
500
- now,
501
- params.workspacePath,
502
- );
503
- addDocumentConversation(params.surfaceId, params.conversationId);
504
- log.info(
505
- { surfaceId: params.surfaceId, workspacePath: params.workspacePath },
506
- "Created file-backed document",
507
- );
508
- return { success: true };
509
- } catch (error) {
510
- log.error(
511
- { err: error, workspacePath: params.workspacePath },
512
- "File-backed document create error",
513
- );
514
- return {
515
- success: false,
516
- error: error instanceof Error ? error.message : "Unknown error",
517
- };
518
- }
519
- }
520
-
521
- /**
522
- * Move file-backed documents onto the path a workspace rename moved their file
523
- * to. `oldPath` and `newPath` are canonical workspace-relative paths, the same
524
- * form {@link createFileBackedDocument} stores.
525
- *
526
- * A document bound to `oldPath` itself moves, and so does every document nested
527
- * under it (`oldPath + "/"` prefix), which is what a directory rename produces.
528
- *
529
- * The rename on disk has already happened by the time this runs, so it is best
530
- * effort and never throws: the caller has no way to undo the rename, and a
531
- * document left on a stale path is recoverable while a failed rename response
532
- * is not. Rows move one statement at a time so a single row that cannot move
533
- * does not strand its siblings.
534
- *
535
- * A row whose destination is already taken by another binding is one such case:
536
- * the partial unique index on `workspace_path` rejects the update, and the
537
- * losing row keeps pointing at the old path. That is deliberate. Overwriting
538
- * the winner would drop a binding that a client may have open, whereas the
539
- * stale binding self-heals: opening the file refreshes the row's content from
540
- * disk, and a document whose file is gone is reported as gone rather than
541
- * rewritten from the row.
542
- */
543
- export function rebindDocumentsToRenamedPath(params: {
544
- oldPath: string;
545
- newPath: string;
546
- }): void {
547
- const { oldPath, newPath } = params;
548
- if (!oldPath || !newPath || oldPath === newPath) {
549
- return;
550
- }
551
-
552
- try {
553
- const rows = rawAll<{ surface_id: string; workspace_path: string }>(
554
- "documents:rebindRenamedPath:select",
555
- /*sql*/ `SELECT surface_id, workspace_path
556
- FROM documents
557
- WHERE workspace_path = ? OR workspace_path LIKE ? ESCAPE '\\'`,
558
- oldPath,
559
- `${escapeSqlLikePattern(oldPath)}/%`,
560
- );
561
-
562
- for (const row of rows) {
563
- const rebound =
564
- row.workspace_path === oldPath
565
- ? newPath
566
- : newPath + row.workspace_path.slice(oldPath.length);
567
- try {
568
- rawRun(
569
- "documents:rebindRenamedPath:update",
570
- /*sql*/ `UPDATE documents SET workspace_path = ? WHERE surface_id = ?`,
571
- rebound,
572
- row.surface_id,
573
- );
574
- log.info(
575
- {
576
- surfaceId: row.surface_id,
577
- from: row.workspace_path,
578
- to: rebound,
579
- },
580
- "Rebound file-backed document to renamed path",
581
- );
582
- } catch (error) {
583
- log.warn(
584
- {
585
- err: error,
586
- surfaceId: row.surface_id,
587
- from: row.workspace_path,
588
- to: rebound,
589
- },
590
- "Left file-backed document on its old path after a rename",
591
- );
592
- }
593
- }
594
- } catch (error) {
595
- log.error({ err: error, oldPath, newPath }, "Path rebind error");
596
- }
597
- }
598
-
599
- /**
600
- * Replace a document's stored content with the current text of the file it is
601
- * bound to. The file is the source of truth when a document is opened, so this
602
- * deliberately skips the write-through step — disk and row already agree.
603
- */
604
- export function refreshDocumentContentFromFile(
605
- surfaceId: string,
606
- content: string,
607
- ): { success: true } | { success: false; error: string } {
608
- try {
609
- rawRun(
610
- "documents:refreshDocumentContentFromFile",
611
- /*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
612
- content,
613
- countWords(content),
614
- Date.now(),
615
- surfaceId,
616
- );
617
- log.info({ surfaceId }, "Refreshed document content from file");
618
- return { success: true };
619
- } catch (error) {
620
- log.error({ err: error, surfaceId }, "Document refresh error");
621
- return {
622
- success: false,
623
- error: error instanceof Error ? error.message : "Unknown error",
624
- };
625
- }
626
- }
627
-
628
401
  // ---------------------------------------------------------------------------
629
402
  // Document persistence
630
403
  // ---------------------------------------------------------------------------
@@ -637,7 +410,6 @@ export function saveDocument(params: {
637
410
  wordCount: number;
638
411
  }): { success: true; surfaceId: string } | { success: false; error: string } {
639
412
  try {
640
- writeThroughToWorkspaceFile(params.surfaceId, params.content);
641
413
  const now = Date.now();
642
414
  rawRun(
643
415
  "documents:saveDocument",
@@ -773,7 +545,6 @@ export function replaceInDocument(
773
545
  replacementsMade = totalMatches;
774
546
  }
775
547
 
776
- writeThroughToWorkspaceFile(surfaceId, newContent);
777
548
  const wordCount = countWords(newContent);
778
549
  rawRun(
779
550
  "documents:replaceInDocument:update",
@@ -940,7 +711,6 @@ export function updateDocumentContent(
940
711
  const newContent = appending
941
712
  ? existing.content + sep + appliedMarkdown
942
713
  : appliedMarkdown;
943
- writeThroughToWorkspaceFile(surfaceId, newContent);
944
714
  const wordCount = countWords(newContent);
945
715
  rawRun(
946
716
  "documents:updateDocumentContent:update",
@@ -173,6 +173,11 @@ export interface UserPromptSubmitInputContext {
173
173
  * user speech (e.g. title generation) should skip these turns.
174
174
  */
175
175
  readonly isHiddenPrompt?: boolean;
176
+ /**
177
+ * Daemon-authored kind from the triggering row's `metadata.messageKind`,
178
+ * when the turn needs a more specific identity than role or visibility.
179
+ */
180
+ readonly messageKind?: string;
176
181
  /**
177
182
  * The user's original message list, immutable for the hook. Plugins
178
183
  * may snapshot or compare against this but MUST NOT mutate it.
@@ -1,6 +1,7 @@
1
1
  import { connect, type Socket } from "node:net";
2
2
 
3
3
  import { refreshOverridesFromGateway } from "../config/assistant-feature-flags.js";
4
+ import { getBalancedModelExperimentArm } from "../config/balanced-model-experiment.js";
4
5
  import { reconcileFlagGatedProfiles } from "../config/sync-gated-profiles.js";
5
6
  import { SYNC_TAGS } from "../daemon/message-types/sync.js";
6
7
  import { publishConfigChanged } from "../runtime/sync/resource-sync-events.js";
@@ -19,13 +20,26 @@ const log = getLogger("gateway-flag-listener");
19
20
  * when the refresh confirmed flags loaded from the gateway — a transient IPC
20
21
  * failure leaves the cache unset and resolves `os-beta` to its registry default
21
22
  * `false`, which would remove the user's profile and reset their selection.
23
+ *
24
+ * A new balanced-model experiment arm gets the same broadcast. It repoints what
25
+ * the managed Balanced profile resolves to without moving anything on disk, so
26
+ * the reconcile reports no change and clients would otherwise keep rendering
27
+ * the previous model beside a profile already running the new one.
22
28
  */
23
29
  function refreshFlagsAndReconcileProfiles(context: string): void {
30
+ const balancedArmBefore = getBalancedModelExperimentArm();
24
31
  refreshOverridesFromGateway()
25
32
  .then((loaded) => {
26
- if (loaded && reconcileFlagGatedProfiles()) {
27
- // Reuse the config-changed broadcast clients already consume so the
28
- // profile picker reflects the added/removed managed profile.
33
+ if (!loaded) {
34
+ return;
35
+ }
36
+ const profilesChanged = reconcileFlagGatedProfiles();
37
+ const balancedArmChanged =
38
+ getBalancedModelExperimentArm() !== balancedArmBefore;
39
+ if (profilesChanged || balancedArmChanged) {
40
+ // Reuse the config-changed broadcast clients already consume: web
41
+ // routes the `assistant:self:config` sync tag to the effective profile
42
+ // catalog and the call-site catalog, both derived from config.
29
43
  publishConfigChanged();
30
44
  }
31
45
  })
@@ -75,11 +75,18 @@ class MockFluxTranscriber implements StreamingTranscriber {
75
75
  this.received.push(Buffer.from(chunk));
76
76
  }
77
77
 
78
+ // Transcript Flux answers `CloseStream` with, for a turn still in flight
79
+ // when the caller released. Null models a stop with nothing left to flush.
80
+ pendingFlushText: string | null = null;
81
+
78
82
  stop(): void {
79
83
  if (this.stopped) {
80
84
  return;
81
85
  }
82
86
  this.stopped = true;
87
+ if (this.pendingFlushText !== null) {
88
+ this.onEvent?.({ type: "final", text: this.pendingFlushText });
89
+ }
83
90
  this.onEvent?.({ type: "closed" });
84
91
  }
85
92
 
@@ -929,4 +936,115 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
929
936
 
930
937
  await session.close("client_end");
931
938
  });
939
+
940
+ test("keeps one stream across turns when the provider owns the boundary", async () => {
941
+ const { session, transcribers, turnCalls } = createHarness({
942
+ fluxConfig: FLUX_ON,
943
+ // Long enough that only the provider can be closing these turns.
944
+ silenceThresholdMs: 10_000,
945
+ startVoiceTurn: autoCompletingTurn(),
946
+ });
947
+
948
+ await session.start();
949
+ await session.handleBinaryAudio(LOUD_CHUNK);
950
+ await waitFor(() => transcribers.length > 0);
951
+ transcribers[0]?.startOfTurn(0);
952
+ transcribers[0]?.endOfTurn("what is the weather", 0);
953
+ await waitFor(() => turnCalls.length === 1);
954
+ await flushAsyncCallbacks();
955
+
956
+ // Second utterance on the same session. A stream torn down per utterance
957
+ // would lose the audio arriving while its replacement dials, which is
958
+ // what eats the opening words of every turn after the first.
959
+ await session.handleBinaryAudio(LOUD_CHUNK);
960
+ transcribers[0]?.startOfTurn(1);
961
+ transcribers[0]?.endOfTurn("and tomorrow", 1);
962
+ await waitFor(() => turnCalls.length === 2);
963
+
964
+ expect(transcribers).toHaveLength(1);
965
+ expect(transcribers[0]?.stopped).toBe(false);
966
+ expect(turnCalls[1]?.content).toBe("and tomorrow");
967
+
968
+ await session.close("client_end");
969
+ });
970
+
971
+ test("closes the stream when a caller-side boundary releases the turn", async () => {
972
+ const { session, transcribers, turnCalls } = createHarness({
973
+ fluxConfig: FLUX_ON,
974
+ startVoiceTurn: autoCompletingTurn(),
975
+ });
976
+
977
+ await session.start();
978
+ await session.handleBinaryAudio(LOUD_CHUNK);
979
+ await waitFor(() => transcribers.length > 0);
980
+ // Flux transcribes but never closes the turn, so the fail-open deadline
981
+ // releases it. Nothing sealed this cycle provider-side, so the only way
982
+ // to make Flux answer for the turn still open is to close the stream.
983
+ transcribers[0]?.emit({ type: "partial", text: "are you still there" });
984
+
985
+ await waitFor(
986
+ () => turnCalls.length === 1,
987
+ "Flux fallback never replayed the silence boundary",
988
+ );
989
+ expect(transcribers[0]?.stopped).toBe(true);
990
+
991
+ // The closing stream stays routable: its flush and its close both arrive
992
+ // after stop(), and the cycle is sealed by that close.
993
+ await session.handleBinaryAudio(LOUD_CHUNK);
994
+ await waitFor(
995
+ () => transcribers.length === 2,
996
+ "The next utterance never dialed a replacement stream",
997
+ );
998
+
999
+ await session.close("client_end");
1000
+ }, 10_000);
1001
+
1002
+ test("routes a closing stream's flush into the turn it released", async () => {
1003
+ const { session, transcribers, turnCalls } = createHarness({
1004
+ fluxConfig: FLUX_ON,
1005
+ startVoiceTurn: autoCompletingTurn(),
1006
+ });
1007
+
1008
+ await session.start();
1009
+ await session.handleBinaryAudio(LOUD_CHUNK);
1010
+ await waitFor(() => transcribers.length > 0);
1011
+ const transcriber = transcribers[0];
1012
+ // Flux transcribes nothing and never closes the turn, so the fail-open
1013
+ // deadline releases a cycle with no transcript: there is no speculative
1014
+ // dispatch to answer this turn, only what the close flushes.
1015
+ if (transcriber) {
1016
+ transcriber.pendingFlushText = "the whole sentence";
1017
+ }
1018
+
1019
+ await waitFor(
1020
+ () => turnCalls.length === 1,
1021
+ "The flushed transcript never reached an assistant turn",
1022
+ );
1023
+ expect(turnCalls[0]?.content).toBe("the whole sentence");
1024
+
1025
+ await session.close("client_end");
1026
+ }, 10_000);
1027
+
1028
+ test("tears the stream down per utterance while the flag is off", async () => {
1029
+ const { session, transcribers, turnCalls } = createHarness({
1030
+ fluxConfig: { turnEnd: { enabled: false } },
1031
+ startVoiceTurn: autoCompletingTurn(),
1032
+ });
1033
+
1034
+ await session.start();
1035
+ await session.handleBinaryAudio(LOUD_CHUNK);
1036
+ await waitFor(() => transcribers.length > 0);
1037
+ transcribers[0]?.emit({ type: "final", text: "hello there" });
1038
+
1039
+ // With provider turn-end disabled the local silence boundary owns the
1040
+ // turn, and a stream the caller can neither flush nor rely on to seal
1041
+ // itself must close for its transcript to land.
1042
+ await waitFor(
1043
+ () => turnCalls.length === 1,
1044
+ "The silence boundary never committed the turn",
1045
+ );
1046
+ expect(transcribers[0]?.stopped).toBe(true);
1047
+
1048
+ await session.close("client_end");
1049
+ }, 10_000);
932
1050
  });
@@ -15,7 +15,17 @@ import { LiveVoiceSessionManager } from "./live-voice-session-manager.js";
15
15
 
16
16
  const require = createRequire(import.meta.url);
17
17
 
18
+ type LiveVoiceSessionFactory =
19
+ typeof import("./live-voice-session.js").createLiveVoiceSession;
20
+
18
21
  let manager: LiveVoiceSessionManager | null = null;
22
+ let bundledSessionFactory: LiveVoiceSessionFactory | null = null;
23
+
24
+ export function setBundledLiveVoiceSessionFactory(
25
+ factory: LiveVoiceSessionFactory | null,
26
+ ): void {
27
+ bundledSessionFactory = factory;
28
+ }
19
29
 
20
30
  /**
21
31
  * The daemon-wide live voice session manager, lazily constructed on first
@@ -32,9 +42,12 @@ export function getLiveVoiceSessionManager(): LiveVoiceSessionManager {
32
42
  // module-load time. `require` keeps the factory synchronous, so the
33
43
  // manager still claims its single-session slot without an await gap.
34
44
  createSession: (context) => {
35
- const { createLiveVoiceSession } =
36
- require("./live-voice-session.js") as typeof import("./live-voice-session.js");
37
- return createLiveVoiceSession(context);
45
+ const createSession =
46
+ bundledSessionFactory ??
47
+ (
48
+ require("./live-voice-session.js") as typeof import("./live-voice-session.js")
49
+ ).createLiveVoiceSession;
50
+ return createSession(context);
38
51
  },
39
52
  });
40
53
  }