@vellumai/assistant 0.11.4-staging.2 → 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 (189) 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 +136 -76
  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__/channel-setup-panel-ack.test.ts +1 -1
  14. package/src/__tests__/compaction-events.test.ts +8 -10
  15. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  16. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  17. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  18. package/src/__tests__/conversation-queue.test.ts +39 -62
  19. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  20. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  22. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  25. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  29. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  31. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  37. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  39. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  40. package/src/__tests__/document-sync-tags.test.ts +0 -75
  41. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  42. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  43. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  44. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  45. package/src/__tests__/media-generate-image.test.ts +131 -21
  46. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  47. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  48. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  49. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  50. package/src/__tests__/qdrant-manager.test.ts +14 -1
  51. package/src/__tests__/run-due-schedules.test.ts +21 -0
  52. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  53. package/src/__tests__/schedule-routes.test.ts +23 -0
  54. package/src/__tests__/schedule-store.test.ts +17 -0
  55. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  56. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  57. package/src/__tests__/starter-task-flow.test.ts +5 -4
  58. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  59. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  61. package/src/__tests__/subagent-tools.test.ts +81 -101
  62. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  63. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  64. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  65. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  66. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  67. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  68. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  69. package/src/acp/session-manager.ts +8 -1
  70. package/src/api/surfaces.ts +5 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  72. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  73. package/src/calls/voice-session-bridge.ts +44 -25
  74. package/src/calls/voice-triage-escalate.ts +1 -0
  75. package/src/cli/bundled-modules.ts +29 -0
  76. package/src/cli/commands/db/repair.ts +4 -8
  77. package/src/cli/commands/domain.ts +6 -3
  78. package/src/cli/commands/email.ts +6 -3
  79. package/src/cli/commands/keys.ts +8 -3
  80. package/src/cli/commands/plugins.ts +85 -36
  81. package/src/cli/commands/schedules.ts +35 -1
  82. package/src/cli/lib/bundled-marketplace.json +1 -1
  83. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  84. package/src/config/assistant-feature-flags.ts +36 -15
  85. package/src/config/balanced-model-experiment.ts +35 -0
  86. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  87. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  89. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  90. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  91. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  92. package/src/config/call-site-defaults.ts +7 -0
  93. package/src/config/default-profile-catalog.ts +96 -4
  94. package/src/config/feature-flag-registry.json +11 -11
  95. package/src/config/skills.ts +9 -2
  96. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  97. package/src/daemon/conversation-agent-loop.ts +14 -13
  98. package/src/daemon/conversation-notifiers.ts +11 -9
  99. package/src/daemon/conversation-process.ts +0 -27
  100. package/src/daemon/conversation-store.ts +4 -4
  101. package/src/daemon/conversation-surfaces.ts +27 -13
  102. package/src/daemon/conversation-tool-setup.ts +2 -4
  103. package/src/daemon/conversation.ts +81 -44
  104. package/src/daemon/doordash-steps.ts +2 -2
  105. package/src/daemon/lifecycle.ts +14 -1
  106. package/src/daemon/process-message.ts +0 -13
  107. package/src/daemon/windows-compiled-entry.ts +4 -0
  108. package/src/documents/document-store.ts +5 -235
  109. package/src/hooks/types.ts +5 -0
  110. package/src/ipc/gateway-flag-listener.ts +17 -3
  111. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  112. package/src/live-voice/live-voice-manager.ts +16 -3
  113. package/src/live-voice/live-voice-session.ts +63 -9
  114. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  115. package/src/monitoring/control.ts +1 -0
  116. package/src/monitoring/db-integrity-sample.ts +4 -5
  117. package/src/permissions/prompter.ts +1 -5
  118. package/src/persistence/conversation-queries.ts +66 -16
  119. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  120. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  121. package/src/persistence/schema/documents.ts +4 -4
  122. package/src/plugin-api/constants.ts +8 -0
  123. package/src/plugin-api/index.ts +5 -1
  124. package/src/plugin-api/webhook-url.ts +13 -11
  125. package/src/plugins/defaults/main.ts +6 -7
  126. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  127. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  128. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  129. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  130. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  131. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  132. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  133. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  134. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  135. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  136. package/src/plugins/mtime-cache.ts +17 -0
  137. package/src/prompts/templates/system-sections.ts +0 -7
  138. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  139. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  140. package/src/providers/openai/chat-completions-provider.ts +11 -1
  141. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  142. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  143. package/src/routes/control.ts +1 -0
  144. package/src/routes/route-host-client.ts +1 -0
  145. package/src/runtime/AGENTS.md +16 -17
  146. package/src/runtime/agent-wake.ts +15 -12
  147. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  148. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  149. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  150. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  151. package/src/runtime/routes/conversation-routes.ts +11 -13
  152. package/src/runtime/routes/documents-routes.ts +3 -222
  153. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  154. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  155. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  156. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  157. package/src/runtime/routes/schedule-routes.ts +94 -7
  158. package/src/runtime/routes/workspace-routes.ts +0 -9
  159. package/src/runtime/routes/workspace-utils.ts +3 -13
  160. package/src/runtime/services/conversation-serializer.ts +7 -2
  161. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  162. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  163. package/src/schedule/plugin-schedule-availability.ts +58 -0
  164. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  165. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  166. package/src/schedule/schedule-store.ts +5 -1
  167. package/src/schedule/scheduler.ts +9 -4
  168. package/src/schedule/worker-control.ts +1 -0
  169. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  170. package/src/subagent/consult-context.ts +11 -11
  171. package/src/subagent/consult-prompt.ts +26 -35
  172. package/src/subagent/manager.ts +20 -37
  173. package/src/subagent/notify.ts +7 -1
  174. package/src/subagent/types.ts +8 -6
  175. package/src/tools/acp/spawn.ts +6 -4
  176. package/src/tools/skills/scaffold-managed.ts +25 -7
  177. package/src/tools/subagent/spawn.ts +28 -88
  178. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  179. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  180. package/src/util/logger.ts +16 -0
  181. package/src/util/worker-process.ts +37 -4
  182. package/src/windows-compiled-cli.ts +32 -0
  183. package/src/windows-compiled-entry.ts +4 -0
  184. package/src/windows-compiled-logger.ts +6 -0
  185. package/src/windows-compiled-worker-entry.ts +29 -0
  186. package/src/__tests__/document-workspace-file.test.ts +0 -467
  187. package/src/daemon/interactive-turn-sender.ts +0 -59
  188. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  189. package/src/subagent/consult-transcript.ts +0 -90
@@ -17,12 +17,12 @@
17
17
 
18
18
  import { existsSync, statSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
20
 
22
21
  import { getRawShareAnalytics } from "../platform/consent-cache.js";
23
22
  import { recordWatchdogEvent } from "../telemetry/watchdog-events-store.js";
24
23
  import { getLogger } from "../util/logger.js";
25
24
  import { getDbPath, getMonitoringDataDir } from "../util/platform.js";
25
+ import { resolveWorkerCommand } from "../util/worker-process.js";
26
26
  import type { IntegritySampleResult } from "./db-integrity-check.js";
27
27
 
28
28
  const log = getLogger("db-integrity-sample");
@@ -60,13 +60,12 @@ let activeChild: ReturnType<typeof Bun.spawn> | null = null;
60
60
  async function runCheckSubprocess(
61
61
  dbPath: string,
62
62
  ): Promise<IntegritySampleResult | null> {
63
- // `fileURLToPath`, not `.pathname`: the latter percent-encodes, and an
64
- // install path with a space in it would not resolve. See worker-process.ts.
65
- const entry = fileURLToPath(
63
+ const command = resolveWorkerCommand(
66
64
  new URL("./db-integrity-check.ts", import.meta.url),
65
+ "db-integrity",
67
66
  );
68
67
  const child = Bun.spawn({
69
- cmd: ["bun", "--smol", "run", entry, dbPath],
68
+ cmd: [...command, dbPath],
70
69
  stdio: ["ignore", "pipe", "ignore"],
71
70
  });
72
71
  activeChild = child;
@@ -36,7 +36,7 @@ export class PermissionPrompter {
36
36
  * pendingInteractions, matching the host proxy pattern.
37
37
  */
38
38
  private ownedIds = new Set<string>();
39
- private sendToClient: (msg: AssistantEvent) => void;
39
+ private readonly sendToClient: (msg: AssistantEvent) => void;
40
40
  private onStateChanged?: ConfirmationStateCallback;
41
41
 
42
42
  constructor(sendToClient: (msg: AssistantEvent) => void) {
@@ -47,10 +47,6 @@ export class PermissionPrompter {
47
47
  this.onStateChanged = cb;
48
48
  }
49
49
 
50
- updateSender(sendToClient: (msg: AssistantEvent) => void): void {
51
- this.sendToClient = sendToClient;
52
- }
53
-
54
50
  async prompt(
55
51
  toolName: string,
56
52
  input: Record<string, unknown>,
@@ -1,4 +1,14 @@
1
- import { and, count, desc, eq, isNull, lt, sql } from "drizzle-orm";
1
+ import {
2
+ and,
3
+ count,
4
+ desc,
5
+ eq,
6
+ getTableColumns,
7
+ isNull,
8
+ lt,
9
+ sql,
10
+ } from "drizzle-orm";
11
+ import type { SQLiteSelect } from "drizzle-orm/sqlite-core";
2
12
 
3
13
  import {
4
14
  parseExternalContentEnvelope,
@@ -263,6 +273,15 @@ export interface ConversationListFilter {
263
273
  * section, and a UUID one custom group. Omit to span every group.
264
274
  */
265
275
  groupId?: string;
276
+ /**
277
+ * Restrict to conversations whose latest assistant message the user has
278
+ * not seen: the same "unseen" predicate the unread count and the section
279
+ * index apply ({@link unseenAttentionStateConditions}), so every reader
280
+ * of "needs attention" agrees. Only `true` narrows; omit to span every
281
+ * conversation. A client that keeps no complete list can ask for exactly
282
+ * the rows its attention surfaces need instead of scanning for them.
283
+ */
284
+ needsAttention?: true;
266
285
  }
267
286
 
268
287
  export interface ConversationListQuery extends ConversationListFilter {
@@ -327,21 +346,55 @@ function originChannelClause(originChannel: string) {
327
346
  return eq(conversations.originChannel, originChannel);
328
347
  }
329
348
 
349
+ /**
350
+ * The join between a conversation and its attention projection, one row per
351
+ * conversation in `conversation_assistant_attention_state`. Every reader of
352
+ * "unseen" joins on this: the list and count filters, the unread count, and
353
+ * the section index.
354
+ */
355
+ const attentionJoin = eq(
356
+ conversationAssistantAttentionState.conversationId,
357
+ conversations.id,
358
+ );
359
+
330
360
  function conversationListWhere(filter: ConversationListFilter) {
331
361
  const {
332
362
  conversationType = "standard",
333
363
  archiveStatus = "active",
334
364
  originChannel,
335
365
  groupId,
366
+ needsAttention,
336
367
  } = filter;
337
368
  return and(
338
369
  conversationTypeClause(conversationType),
339
370
  archiveStatusClause(archiveStatus) ?? undefined,
340
371
  originChannel ? originChannelClause(originChannel) : undefined,
341
372
  groupId ? groupIdClause(groupId) : undefined,
373
+ ...(needsAttention ? unseenAttentionStateConditions() : []),
342
374
  );
343
375
  }
344
376
 
377
+ /**
378
+ * The joins {@link conversationListWhere}'s predicates need, applied to a
379
+ * list or count query over `conversations`. The attention predicate lives on
380
+ * `conversation_assistant_attention_state`, so `needsAttention` joins it.
381
+ * Inner, not left: a conversation with no
382
+ * attention row has no unseen message by definition. Joined only when the
383
+ * filter asks, so a query without the filter runs without the join and
384
+ * lists every conversation the predicates admit. One place for both
385
+ * {@link listConversations} and {@link countConversations}, so a page and
386
+ * its total always describe the same set.
387
+ */
388
+ function withListFilterJoins<T extends SQLiteSelect>(
389
+ query: T,
390
+ filter: ConversationListFilter,
391
+ ) {
392
+ if (!filter.needsAttention) {
393
+ return query;
394
+ }
395
+ return query.innerJoin(conversationAssistantAttentionState, attentionJoin);
396
+ }
397
+
345
398
  export function listConversations(
346
399
  query: ConversationListQuery = {},
347
400
  ): ConversationRow[] {
@@ -362,9 +415,13 @@ export function listConversations(
362
415
  // distinguish a total order from a lucky one. The guarantee becomes
363
416
  // observable, and gets its test, with the keyset pagination work.
364
417
  const tiebreak = desc(conversations.id);
365
- return db
366
- .select()
367
- .from(conversations)
418
+ // Selecting the conversation columns explicitly keeps the row shape
419
+ // `parseConversation` maps identical whether or not the attention join is
420
+ // present.
421
+ return withListFilterJoins(
422
+ db.select(getTableColumns(conversations)).from(conversations).$dynamic(),
423
+ filter,
424
+ )
368
425
  .where(conversationListWhere(filter))
369
426
  .orderBy(recency, tiebreak)
370
427
  .limit(limit ?? 100)
@@ -542,9 +599,10 @@ export function countConversations(
542
599
  ): number {
543
600
  ensureGroupMigration();
544
601
  const db = getDb();
545
- const [{ total }] = db
546
- .select({ total: count() })
547
- .from(conversations)
602
+ const [{ total }] = withListFilterJoins(
603
+ db.select({ total: count() }).from(conversations).$dynamic(),
604
+ filter,
605
+ )
548
606
  .where(conversationListWhere(filter))
549
607
  .all();
550
608
  return total;
@@ -582,10 +640,7 @@ export function countUnreadConversations(): number {
582
640
  const [{ total }] = db
583
641
  .select({ total: count() })
584
642
  .from(conversations)
585
- .innerJoin(
586
- conversationAssistantAttentionState,
587
- eq(conversationAssistantAttentionState.conversationId, conversations.id),
588
- )
643
+ .innerJoin(conversationAssistantAttentionState, attentionJoin)
589
644
  .where(
590
645
  and(
591
646
  conversationTypeClause("standard"),
@@ -647,11 +702,6 @@ export function countConversationSections(): ConversationSectionCounts {
647
702
  ...unseenAttentionStateConditions(),
648
703
  )} THEN 1 ELSE 0 END)`;
649
704
 
650
- const attentionJoin = eq(
651
- conversationAssistantAttentionState.conversationId,
652
- conversations.id,
653
- );
654
-
655
705
  const groups = db
656
706
  .select({
657
707
  groupId: sql<string>`group_id`,
@@ -33,6 +33,45 @@ const SHUTDOWN_GRACE_MS = 5_000;
33
33
  const STDOUT_CAPTURE_LIMIT = 16_384;
34
34
  const STDERR_CAPTURE_LIMIT = 4_096;
35
35
 
36
+ export function resolveQdrantReleaseAsset(
37
+ os: NodeJS.Platform,
38
+ cpu: string,
39
+ ): {
40
+ binaryName: string;
41
+ filename: string;
42
+ format: "tar.gz" | "zip";
43
+ sha256?: string;
44
+ } {
45
+ let target: string;
46
+ if (os === "darwin" && cpu === "arm64") {
47
+ target = "aarch64-apple-darwin";
48
+ } else if (os === "darwin" && cpu === "x64") {
49
+ target = "x86_64-apple-darwin";
50
+ } else if (os === "linux" && cpu === "x64") {
51
+ target = "x86_64-unknown-linux-musl";
52
+ } else if (os === "linux" && cpu === "arm64") {
53
+ target = "aarch64-unknown-linux-musl";
54
+ } else if (os === "win32" && cpu === "x64") {
55
+ return {
56
+ binaryName: "qdrant.exe",
57
+ filename: "qdrant-x86_64-pc-windows-msvc.zip",
58
+ format: "zip",
59
+ sha256:
60
+ "9d3b1d1fa58566bc71709347c4c9b83a0111d23a550daa93c3ee48e3150c4470",
61
+ };
62
+ } else {
63
+ throw new Error(
64
+ `Unsupported platform: ${os}/${cpu}. ` +
65
+ "Set QDRANT_URL to use an external Qdrant instance.",
66
+ );
67
+ }
68
+ return {
69
+ binaryName: "qdrant",
70
+ filename: `qdrant-${target}.tar.gz`,
71
+ format: "tar.gz",
72
+ };
73
+ }
74
+
36
75
  export interface QdrantManagerConfig {
37
76
  url: string;
38
77
  storagePath?: string;
@@ -181,34 +220,18 @@ export class QdrantManager {
181
220
  private async installBinary(binaryPath: string): Promise<void> {
182
221
  const os = platform();
183
222
  const cpu = arch();
184
-
185
- let target: string;
186
- if (os === "darwin" && cpu === "arm64") {
187
- target = "aarch64-apple-darwin";
188
- } else if (os === "darwin" && cpu === "x64") {
189
- target = "x86_64-apple-darwin";
190
- } else if (os === "linux" && cpu === "x64") {
191
- target = "x86_64-unknown-linux-musl";
192
- } else if (os === "linux" && cpu === "arm64") {
193
- target = "aarch64-unknown-linux-musl";
194
- } else {
195
- throw new Error(
196
- `Unsupported platform: ${os}/${cpu}. ` +
197
- "Set QDRANT_URL to use an external Qdrant instance.",
198
- );
199
- }
200
-
201
- const filename = `qdrant-${target}.tar.gz`;
223
+ const release = resolveQdrantReleaseAsset(os, cpu);
224
+ const { filename } = release;
202
225
  const baseUrl = `https://github.com/qdrant/qdrant/releases/download/v${QDRANT_VERSION}`;
203
226
  const url = `${baseUrl}/${filename}`;
204
227
  const checksumUrl = `${baseUrl}/${filename}.sha256`;
205
228
 
206
229
  log.info({ url, binaryPath }, "Downloading Qdrant binary");
207
230
 
208
- // Fetch the tarball and its SHA-256 checksum in parallel
231
+ // Fetch the archive and its SHA-256 checksum in parallel
209
232
  const [response, checksumResponse] = await Promise.all([
210
233
  fetch(url),
211
- fetch(checksumUrl),
234
+ release.sha256 ? Promise.resolve(null) : fetch(checksumUrl),
212
235
  ]);
213
236
 
214
237
  if (!response.ok) {
@@ -217,15 +240,17 @@ export class QdrantManager {
217
240
  );
218
241
  }
219
242
 
220
- const tarball = await response.arrayBuffer();
243
+ const archive = await response.arrayBuffer();
221
244
 
222
- // Verify SHA-256 integrity if the checksum file is available
223
- if (checksumResponse.ok) {
224
- const checksumText = (await checksumResponse.text()).trim();
245
+ // Verify SHA-256 integrity if a pinned or published checksum is available
246
+ if (release.sha256 || checksumResponse?.ok) {
247
+ const checksumText = release.sha256
248
+ ? release.sha256
249
+ : (await checksumResponse!.text()).trim();
225
250
  // Checksum files contain "<hex> <filename>" or just "<hex>"
226
251
  const expectedHash = checksumText.split(/\s+/)[0].toLowerCase();
227
252
  const actualHash = createHash("sha256")
228
- .update(Buffer.from(tarball))
253
+ .update(Buffer.from(archive))
229
254
  .digest("hex");
230
255
 
231
256
  if (actualHash !== expectedHash) {
@@ -237,39 +262,45 @@ export class QdrantManager {
237
262
  log.info({ hash: actualHash }, "Qdrant binary checksum verified");
238
263
  } else {
239
264
  log.warn(
240
- { checksumUrl, status: checksumResponse.status },
241
- "Could not fetch Qdrant checksum — skipping integrity check",
265
+ { checksumUrl, status: checksumResponse?.status },
266
+ "Could not fetch Qdrant checksum; skipping integrity check",
242
267
  );
243
268
  }
244
269
 
245
- // Extract the qdrant binary from the tarball
246
270
  const binDir = dirname(binaryPath);
247
271
  mkdirSync(binDir, { recursive: true });
248
-
249
- // Write tarball to temp file, extract with tar
250
- const tmpTar = join(binDir, `qdrant-download-${Date.now()}.tar.gz`);
251
- writeFileSync(tmpTar, Buffer.from(tarball));
252
-
253
- try {
254
- const proc = Bun.spawn({
255
- cmd: ["tar", "xzf", tmpTar, "-C", binDir, "qdrant"],
256
- stdout: "ignore",
257
- stderr: "pipe",
258
- });
259
- await proc.exited;
260
- if (proc.exitCode !== 0) {
261
- const stderr = await new Response(proc.stderr).text();
262
- throw new Error(`Failed to extract Qdrant binary: ${stderr}`);
272
+ if (release.format === "zip") {
273
+ const { default: JSZip } = await import("jszip");
274
+ const zip = await JSZip.loadAsync(archive);
275
+ const entry = zip.file(release.binaryName);
276
+ if (!entry) {
277
+ throw new Error(`${filename} does not contain ${release.binaryName}.`);
263
278
  }
264
- } finally {
279
+ writeFileSync(binaryPath, await entry.async("nodebuffer"));
280
+ } else {
281
+ const tmpTar = join(binDir, `qdrant-download-${Date.now()}.tar.gz`);
282
+ writeFileSync(tmpTar, Buffer.from(archive));
265
283
  try {
266
- unlinkSync(tmpTar);
267
- } catch {
268
- /* ignore */
284
+ const proc = Bun.spawn({
285
+ cmd: ["tar", "xzf", tmpTar, "-C", binDir, release.binaryName],
286
+ stdout: "ignore",
287
+ stderr: "pipe",
288
+ });
289
+ await proc.exited;
290
+ if (proc.exitCode !== 0) {
291
+ const stderr = await new Response(proc.stderr).text();
292
+ throw new Error(`Failed to extract Qdrant binary: ${stderr}`);
293
+ }
294
+ } finally {
295
+ try {
296
+ unlinkSync(tmpTar);
297
+ } catch {}
269
298
  }
270
299
  }
271
300
 
272
- chmodSync(binaryPath, 0o755);
301
+ if (os !== "win32") {
302
+ chmodSync(binaryPath, 0o755);
303
+ }
273
304
  log.info(
274
305
  { binaryPath, version: QDRANT_VERSION },
275
306
  "Qdrant binary installed",
@@ -395,7 +426,8 @@ export class QdrantManager {
395
426
  }
396
427
 
397
428
  private getBinaryPath(): string {
398
- return join(getDataDir(), "qdrant", "bin", "qdrant");
429
+ const binaryName = platform() === "win32" ? "qdrant.exe" : "qdrant";
430
+ return join(getDataDir(), "qdrant", "bin", binaryName);
399
431
  }
400
432
 
401
433
  private cleanupStaleProcess(): void {
@@ -447,6 +479,9 @@ export class QdrantManager {
447
479
  * discoverable by tools that scan for "vellum" in process names.
448
480
  */
449
481
  private ensureVellumSymlink(binaryPath: string): string {
482
+ if (platform() === "win32") {
483
+ return binaryPath;
484
+ }
450
485
  const symlinkPath = join(dirname(binaryPath), "vellum-qdrant");
451
486
  const expectedTarget = realpathSync(binaryPath);
452
487
 
@@ -7,20 +7,11 @@ const COLUMN_DEFINITION = "workspace_path TEXT";
7
7
  /**
8
8
  * Add `workspace_path` to `documents` and enforce one document per file.
9
9
  *
10
- * The column holds the workspace-relative path of the markdown file a document
11
- * is bound to. It is set when a client opens a workspace `.md` file through
12
- * `POST /v1/documents/for-workspace-file`, which gives the file a document
13
- * identity so comments, assistant iteration, and PDF export — all keyed off a
14
- * document id — work on it. Documents that have no file behind them (assistant
15
- * drafts, client-created surfaces) stay NULL.
16
- *
17
- * The unique index is partial (`WHERE workspace_path IS NOT NULL`) so the many
18
- * NULL rows stay unconstrained while a given file resolves to exactly one
19
- * document. The find-or-create route depends on that uniqueness for its
20
- * idempotency.
21
- *
22
- * Nullable with no backfill: nothing recorded a file binding before this
23
- * column existed, so pre-existing rows correctly stay NULL.
10
+ * The column and its partial unique index are unused by the daemon: no code
11
+ * reads or writes them. Some rows carry a non-NULL path; new rows stay NULL.
12
+ * The migration stays in the chain because migrations are append-only, and the
13
+ * drizzle model in `schema/documents.ts` carries the column so it keeps
14
+ * describing the physical table.
24
15
  *
25
16
  * Idempotent: the column add is guarded with `tableHasColumn` and the index
26
17
  * with `IF NOT EXISTS`, so a crash between the two does not break the next
@@ -22,13 +22,13 @@ export const documents = sqliteTable(
22
22
  wordCount: integer("word_count").notNull().default(0),
23
23
  createdAt: integer("created_at").notNull(),
24
24
  updatedAt: integer("updated_at").notNull(),
25
- // Workspace-relative path of the markdown file this document is bound to.
26
- // NULL for documents with no file behind them.
25
+ // Unused by the daemon: no code reads or writes the column, and some rows
26
+ // carry a non-NULL path. It stays in the model because migration 360 puts
27
+ // it in the physical table and the model must keep describing that table.
27
28
  workspacePath: text("workspace_path"),
28
29
  },
29
30
  (table) => [
30
- // Partial so the many file-less documents stay unconstrained while a given
31
- // file resolves to exactly one document.
31
+ // Partial, so the NULL rows stay unconstrained.
32
32
  uniqueIndex("idx_documents_workspace_path")
33
33
  .on(table.workspacePath)
34
34
  .where(sql`workspace_path IS NOT NULL`),
@@ -39,6 +39,14 @@ export const HOOKS = {
39
39
  /** Union of every hook name declared in {@link HOOKS}. */
40
40
  export type HookName = (typeof HOOKS)[keyof typeof HOOKS];
41
41
 
42
+ /**
43
+ * Message kind for the hidden user row that starts a voice escalation leg.
44
+ * Hooks use this marker to distinguish the continuation instruction from
45
+ * other transcript-suppressed prompts without depending on host voice code.
46
+ */
47
+ export const VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND =
48
+ "voice_escalation_continuation";
49
+
42
50
  /**
43
51
  * Appended (inside the `<system_notice>` wrapper) to the internal continuation /
44
52
  * completion nudge strings that re-query the model. Those notices are injected
@@ -67,7 +67,11 @@
67
67
  */
68
68
 
69
69
  export type { HookName } from "./constants.js";
70
- export { HOOKS, INTERNAL_NUDGE_OUTPUT_SUPPRESSION } from "./constants.js";
70
+ export {
71
+ HOOKS,
72
+ INTERNAL_NUDGE_OUTPUT_SUPPRESSION,
73
+ VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
74
+ } from "./constants.js";
71
75
  // Conversation message/content shapes. A hook receives the live message
72
76
  // history (e.g. `PostToolUseContext.latestMessages: Message[]`), so plugins
73
77
  // that inspect or narrow content blocks — reading a `tool_use` block's input,
@@ -90,22 +90,24 @@ function registrationType(plugin: string, route: string): string {
90
90
  }
91
91
 
92
92
  /**
93
- * Drop a trailing slash from a resolved callback URL.
93
+ * Keep a trailing slash on a resolved callback URL.
94
94
  *
95
- * The platform appends one to the callback URLs it hands back, and a provider
96
- * given `.../events-comms/` delivers to that spelling verbatim. The gateway
97
- * matches a plugin route against its declared path, which may never end in a
98
- * slash (see `IngressRouteSchema`), so the slash comes off once here rather
99
- * than at each plugin that registers a URL.
95
+ * Django in front of managed callbacks canonicalizes onto `/`. A vendor given
96
+ * the slashless spelling is 301'd, and clients that follow a 301 on POST
97
+ * typically retry as GET and drop the body. The gateway serves both spellings
98
+ * of a plugin webhook, so the slashed URL is the one to hand out.
100
99
  *
101
- * Only the path is ever trimmed: this resolver passes no query parameters, and
102
- * a URL carrying one is left alone rather than truncated.
100
+ * Query-bearing URLs are left alone: this resolver passes no query
101
+ * parameters, and appending there would cut into the query rather than the
102
+ * path. The callback path registered with the platform stays slashless. The
103
+ * platform strips slashes on store, and Django's path converter typically
104
+ * does not include the final `/` in the lookup key.
103
105
  */
104
- function withoutTrailingSlash(url: string): string {
106
+ function withTrailingSlash(url: string): string {
105
107
  if (url.includes("?") || url.includes("#")) {
106
108
  return url;
107
109
  }
108
- return url.replace(/\/+$/, "");
110
+ return url.endsWith("/") ? url : `${url}/`;
109
111
  }
110
112
 
111
113
  /**
@@ -147,5 +149,5 @@ export async function resolveWebhookUrl(
147
149
  undefined,
148
150
  sourceIdentifier,
149
151
  );
150
- return withoutTrailingSlash(resolved);
152
+ return withTrailingSlash(resolved);
151
153
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
 
4
+ import { resolveBundledDir } from "../../util/bundled-asset.js";
5
+
4
6
  /**
5
7
  * Filesystem-backed access to the first-party default plugins. This module
6
8
  * will eventually replace `defaults/index.ts`.
@@ -13,12 +15,10 @@ import { dirname, join, resolve } from "node:path";
13
15
  * that carries a `package.json` is one plugin, named by that manifest — the
14
16
  * same `package.json` the barrel imports for its `manifest.name`.
15
17
  *
16
- * Relying on the directory layout is safe because the assistant ships its
17
- * source hierarchy as-is (`bun --compile` is not supported), so the tree this
18
- * module reads in production is the tree the plugins are loaded from.
18
+ * Compiled builds ship this metadata tree beside the executable.
19
19
  */
20
20
 
21
- const DEFAULTS_DIR = import.meta.dir;
21
+ const DEFAULTS_DIR = resolveBundledDir(import.meta.dir, ".", "default-plugins");
22
22
 
23
23
  /**
24
24
  * Route-namespace prefix for default plugins. A default plugin's route
@@ -105,9 +105,8 @@ export function getDefaultPluginManifestName(dirName: string): string | null {
105
105
  * `default-` prefix is stripped to recover the source directory, so a name
106
106
  * without the prefix (e.g. the bare directory name) resolves to `null`. The
107
107
  * containment guard rejects any `..`/nested segment taken from a URL path so it
108
- * can never escape the defaults tree. The path is derived from this module's
109
- * own location (`import.meta.dir`), so it resolves relative to the app source,
110
- * which the assistant always ships and runs un-bundled.
108
+ * can never escape the defaults tree. `DEFAULTS_DIR` resolves either the source
109
+ * tree or the externally packaged compiled metadata tree.
111
110
  */
112
111
  export function getDefaultPluginRoutesDir(name: string): string | null {
113
112
  if (!name.startsWith(DEFAULT_PLUGIN_NAMESPACE_PREFIX)) {
@@ -371,6 +371,38 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
371
371
  expect(result.runMessages).toEqual(messages);
372
372
  });
373
373
 
374
+ test("separate routing history directs v1 retrieval without replacing model history", async () => {
375
+ const memory = makeMemory();
376
+ const config = makeConfig(false);
377
+ const callerText = "What is my preferred editor?";
378
+ const continuationText = "Continue the answer.";
379
+ const routingMessages = makeMessages(callerText);
380
+ const messages: Message[] = [
381
+ ...routingMessages,
382
+ {
383
+ role: "assistant",
384
+ content: [{ type: "text", text: "Let me check that." }],
385
+ },
386
+ {
387
+ role: "user",
388
+ content: [{ type: "text", text: continuationText }],
389
+ },
390
+ ];
391
+
392
+ const result = await memory.prepareMemory(
393
+ messages,
394
+ config,
395
+ new AbortController().signal,
396
+ noopEvent,
397
+ routingMessages,
398
+ );
399
+
400
+ expect(retrieveForTurnMock).toHaveBeenCalledWith(
401
+ expect.objectContaining({ userLastMessage: callerText }),
402
+ );
403
+ expect(result.runMessages).toEqual(messages);
404
+ });
405
+
374
406
  test("config on → v2 block prepended, mode is per-turn", async () => {
375
407
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
376
408
 
@@ -498,6 +530,51 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
498
530
  ).toBe(false);
499
531
  });
500
532
 
533
+ test("separate routing history directs v2 retrieval while injection stays on model history", async () => {
534
+ stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
535
+
536
+ const memory = makeMemory();
537
+ const config = makeConfig(true);
538
+ const callerText = "What is my preferred editor?";
539
+ const continuationText = "Continue the answer.";
540
+ const routingMessages = makeMessages(callerText);
541
+ const messages: Message[] = [
542
+ ...routingMessages,
543
+ {
544
+ role: "assistant",
545
+ content: [{ type: "text", text: "Let me check that." }],
546
+ },
547
+ {
548
+ role: "user",
549
+ content: [{ type: "text", text: continuationText }],
550
+ },
551
+ ];
552
+
553
+ const result = await memory.prepareMemory(
554
+ messages,
555
+ config,
556
+ new AbortController().signal,
557
+ noopEvent,
558
+ routingMessages,
559
+ );
560
+
561
+ expect(generateSparseEmbeddingMock.mock.calls).toContainEqual([callerText]);
562
+ expect(generateSparseEmbeddingMock.mock.calls).not.toContainEqual([
563
+ continuationText,
564
+ ]);
565
+ const tail = result.runMessages[result.runMessages.length - 1];
566
+ expect(tail?.role).toBe("user");
567
+ expect(tail?.content).toContainEqual({
568
+ type: "text",
569
+ text: continuationText,
570
+ });
571
+ expect(tail?.content[0]).toEqual(expect.objectContaining({ type: "text" }));
572
+ if (tail?.content[0]?.type !== "text") {
573
+ throw new Error("unexpected block type");
574
+ }
575
+ expect(tail.content[0].text).toContain("# memory/concepts/alice-vscode.md");
576
+ });
577
+
501
578
  test("config on with empty Qdrant hits → no retrieved concepts in the v2 block, v1 fallback skipped", async () => {
502
579
  // No `stageTurn` call — every channel returns `{ points: [] }` so the
503
580
  // candidate set is empty and retrieval contributes nothing. Always-candidate