@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
@@ -447,6 +447,13 @@ interface UtteranceCycle {
447
447
  // to the silence-boundary path and stays there: a late event must not
448
448
  // commit a boundary the fallback already owns.
449
449
  providerTurnEndTimedOut: boolean;
450
+ // The provider's own end-of-turn released this cycle, so its transcript is
451
+ // already complete. A provider that decides turn ends commits the transcript
452
+ // in the same event, so such a release needs no caller-side flush and must
453
+ // not tear the stream down. False on every caller-side release (the fail-open
454
+ // deadline, a max-duration force-end, barge-in), where a turn may still be
455
+ // open provider-side and only a stream close can make it answer.
456
+ providerClosedTurn: boolean;
450
457
  // `vadSpeechGeneration` as of the local silence boundary that handed this
451
458
  // cycle to the provider, or null while no boundary has fired yet (a turn
452
459
  // model routinely beats the trailing-silence countdown, and that fast commit
@@ -948,6 +955,7 @@ function createUtteranceCycle(): UtteranceCycle {
948
955
  latestPartialText: null,
949
956
  endpointExtensionCount: 0,
950
957
  providerTurnEndTimedOut: false,
958
+ providerClosedTurn: false,
951
959
  turnBoundaryGeneration: null,
952
960
  openProviderTurnIndex: null,
953
961
  heldSpeculativeContent: null,
@@ -1216,12 +1224,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1216
1224
  // replayed once the parked speech flushes into the next armed utterance.
1217
1225
  private vadPendingTurnEnd: "silence" | "max-duration" | null = null;
1218
1226
  private readonly metricsClock: LiveVoiceMetricsClock;
1219
- // Persistent mode: server-VAD sessions with a finalize-capable provider
1220
- // keep one streaming transcriber for the whole session. Utterance release
1221
- // flushes via finalizeUtterance() instead of tearing the stream down, and
1222
- // re-arm reuses this instance synchronously. Null in manual mode, for
1223
- // providers without finalizeUtterance, and after an unexpected stream
1224
- // close (the next arm resolves a fresh transcriber).
1227
+ // Persistent mode: a server-VAD session keeps one streaming transcriber for
1228
+ // the whole session when the stream can be sealed per utterance without
1229
+ // closing it. Either the provider is finalize-capable (release flushes via
1230
+ // finalizeUtterance()) or the provider decides turn ends itself (release is
1231
+ // already sealed by the end-of-turn that triggered it). Re-arm reuses this
1232
+ // instance synchronously. Null in manual mode, for a stream that offers
1233
+ // neither seal, and after an unexpected stream close (the next arm resolves
1234
+ // a fresh transcriber).
1225
1235
  private sharedTranscriber: StreamingTranscriber | null = null;
1226
1236
  // The `services.stt.language` value the shared stream was dialed with.
1227
1237
  // Providers pin the language per connection, so the persistent re-arm
@@ -1635,7 +1645,15 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1635
1645
  );
1636
1646
  if (
1637
1647
  this.turnDetector &&
1638
- typeof transcriber.finalizeUtterance === "function"
1648
+ (typeof transcriber.finalizeUtterance === "function" ||
1649
+ // A provider that owns the turn boundary needs no caller-side flush
1650
+ // on the normal path: its end-of-turn IS the flush, and it commits
1651
+ // the transcript in the same event. Gating persistence on
1652
+ // `finalizeUtterance` alone asks "can the caller flush this stream"
1653
+ // when the question is "will the caller ever have to", and answering
1654
+ // it wrong costs a socket per utterance, which drops the audio that
1655
+ // arrives while the next one dials.
1656
+ this.providerTurnEndActive)
1639
1657
  ) {
1640
1658
  // Adopt persistent mode: this stream serves the whole session, so
1641
1659
  // its events route by cycle ownership instead of binding to this
@@ -3455,6 +3473,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3455
3473
  "provider",
3456
3474
  );
3457
3475
  await this.sendFrame({ type: "utterance_end", reason: "silence" });
3476
+ // The provider committed this cycle's transcript in the event being
3477
+ // handled, so the release below seals it in place instead of flushing or
3478
+ // closing the stream.
3479
+ utterance.providerClosedTurn = true;
3458
3480
  await this.releaseUtterance();
3459
3481
  // Leave the local detector idle, which is where every other commit path
3460
3482
  // leaves it. A provider can close a turn while the trailing-silence
@@ -3723,8 +3745,24 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3723
3745
  ): Promise<void> {
3724
3746
  const shared = this.sharedTranscriber;
3725
3747
  if (shared && utterance.transcriber === shared) {
3726
- await this.finalizeUtteranceForRelease(utterance);
3727
- return;
3748
+ if (typeof shared.finalizeUtterance === "function") {
3749
+ await this.finalizeUtteranceForRelease(utterance);
3750
+ return;
3751
+ }
3752
+ if (utterance.providerClosedTurn) {
3753
+ await this.sealProviderClosedUtterance(utterance);
3754
+ return;
3755
+ }
3756
+ // A caller-side boundary on a stream with no flush: only closing it can
3757
+ // make the provider answer for a turn still open, so this one release
3758
+ // costs the stream. The stream stays installed as the shared one across
3759
+ // the close: its flush `final` and its `closed` both arrive after
3760
+ // `stop()`, and the shared router drops events from a stream it no
3761
+ // longer recognizes. `closed` is what retires it, seals this cycle and
3762
+ // dispatches the turn, so clearing the reference here would strand the
3763
+ // cycle in `released` with the flushed transcript discarded. Nothing can
3764
+ // re-arm onto the closing stream in the meantime: the next arm waits on
3765
+ // the assistant turn, which waits on that same `closed`.
3728
3766
  }
3729
3767
 
3730
3768
  utterance.phase = "released";
@@ -3744,6 +3782,22 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3744
3782
  await this.drainOutboundFrames();
3745
3783
  }
3746
3784
 
3785
+ // Persistent-mode release for a provider-decided turn end: the event that
3786
+ // released this cycle already carried its final transcript, so there is
3787
+ // nothing to flush and no reason to close the stream. Sealing the phase is
3788
+ // the whole of the teardown: the same "transcript complete, stream open"
3789
+ // state the `finalized` path reaches, arrived at without a round trip.
3790
+ private async sealProviderClosedUtterance(
3791
+ utterance: UtteranceCycle,
3792
+ ): Promise<void> {
3793
+ if (utterance.phase === "transcriber_closed") {
3794
+ return;
3795
+ }
3796
+ utterance.phase = "transcriber_closed";
3797
+ await this.startAssistantTurnIfReady();
3798
+ await this.drainOutboundFrames();
3799
+ }
3800
+
3747
3801
  // Persistent-mode release: flush the shared stream's buffered audio with
3748
3802
  // finalizeUtterance() instead of tearing the stream down. The assistant
3749
3803
  // turn starts on the `finalized` event; the grace timer bounds a flush
@@ -0,0 +1,4 @@
1
+ import { setBundledLiveVoiceSessionFactory } from "./live-voice-manager.js";
2
+ import { createLiveVoiceSession } from "./live-voice-session.js";
3
+
4
+ setBundledLiveVoiceSessionFactory(createLiveVoiceSession);
@@ -45,6 +45,7 @@ export async function spawnMonitoringWorkerProcess(
45
45
  return await spawnWorkerProcess({
46
46
  pidPath: getMonitoringPidPath(),
47
47
  entry: new URL("./worker.ts", import.meta.url),
48
+ packagedEntry: "monitoring",
48
49
  workerLabel: "Resource monitor",
49
50
  options: opts,
50
51
  });
@@ -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;
@@ -9,3 +9,5 @@ When a notification flow creates a server-side conversation (e.g. guardian quest
9
9
  Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card or resolve an emoji reaction -- the drift that previously fanned this logic into four divergent copies. (Tests may seed delivery rows directly for fixtures.)
10
10
 
11
11
  Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path.
12
+
13
+ Guardian-request card rows are **not conversation history**. `pairDeliveryWithConversation` persists a message row per delivery, and for a guardian card that row is addressed to a conversation the request is _about_ (the vellum card is pinned to the originating conversation; a channel card lands in whatever conversation the guardian's chat binds to). `isGuardianCardRow` in `approval-card-data.ts` is the single definition of which rows those are, derived from the card's own `ui_surface` id rather than a stored marker so old rows need no backfill. **Both** history assemblers must consult it -- `Conversation.loadFromDb` and `loadSlackChronologicalContext`, which re-reads rows rather than using `this.messages` -- or the unfiltered one replays the card between a parked turn's `tool_use` and its `tool_result` and history repair destroys the real result. Surface state is exempt on purpose: the card's buttons must still route after a restart. Full rationale in [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md).
@@ -75,6 +75,39 @@ export function approvalCardSurfaceId(
75
75
  }
76
76
  }
77
77
 
78
+ /**
79
+ * Whether a persisted message row is a guardian card rather than the
80
+ * conversation's own speech.
81
+ *
82
+ * Read off the card's own `ui_surface` id, using the prefixes above. The card
83
+ * builder writes that block on every path (`ensureSeedContentBlocks`), and the
84
+ * withdrawal path already recomputes the same id from the same constants, so
85
+ * the identity is structural rather than sniffed. Deriving it means rows
86
+ * written before this rule existed are recognized on the same terms as new
87
+ * ones, with no marker to backfill.
88
+ *
89
+ * Only a row that LEADS with an approval-card surface counts. A real assistant
90
+ * turn can carry a `ui_surface` block too (a wake card is unshifted onto the
91
+ * turn's own content), and those surfaces do not use these prefixes; requiring
92
+ * the lead position keeps the rule from ever swallowing a turn that has speech
93
+ * in it.
94
+ */
95
+ export function isGuardianCardRow(
96
+ content: readonly unknown[] | string | null | undefined,
97
+ ): boolean {
98
+ if (!Array.isArray(content) || content.length === 0) {
99
+ return false;
100
+ }
101
+ const lead = content[0] as { type?: unknown; surfaceId?: unknown };
102
+ if (lead?.type !== "ui_surface" || typeof lead.surfaceId !== "string") {
103
+ return false;
104
+ }
105
+ return (
106
+ lead.surfaceId.startsWith(`${ACCESS_REQUEST_SURFACE_PREFIX}-`) ||
107
+ lead.surfaceId.startsWith(`${TOOL_APPROVAL_SURFACE_PREFIX}-`)
108
+ );
109
+ }
110
+
78
111
  // ── Typed card data ─────────────────────────────────────────────────────────
79
112
 
80
113
  /** Resolved card data for an access-request notification. */
@@ -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,