@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,807 @@
1
+ /**
2
+ * The ingress for a watch session: `/v1/watch/stream`, a WebSocket carrying
3
+ * the user's narration while they work.
4
+ *
5
+ * The transport is the one `/v1/stt/stream` already established, and
6
+ * deliberately not a second story: binary frames or base64 `audio` events in,
7
+ * a `{ type: "stop" }` text frame to flush, and a `StreamingTranscriber`
8
+ * resolved by `resolveStreamingTranscriber()` so provider selection,
9
+ * credentials, and language live in exactly one place.
10
+ *
11
+ * What differs is everything downstream of a transcript. Dictation hands its
12
+ * text back to the client; a watch session hands each final to
13
+ * {@link WatchSessionManager}, which files it on the timeline and decides
14
+ * whether the screen is worth reading. So the frames going the other way are
15
+ * lifecycle only: `ready`, `entry`, `observation`, `error`, `closed`. No
16
+ * `partial`, no transcript text, no assistant reply. What the client draws
17
+ * during a session is that the session is running and that its screen was
18
+ * read, never what was said or seen, and the assistant stays silent until the
19
+ * retrospective, which is a conversational turn that happens after the socket
20
+ * is gone.
21
+ *
22
+ * Route policy: the upgrade is gated exactly as `/v1/stt/stream` is, in
23
+ * `http-server.ts`: private-network peer and origin, then an `svc_gateway`
24
+ * service token. A WebSocket upgrade never reaches the shared `ROUTES` array,
25
+ * whose `policy` block the HTTP adapter evaluates per JSON request, so the
26
+ * gate is the upgrade handler's rather than a `RoutePolicy` value. The gateway
27
+ * authenticates the downstream actor before it dials upstream
28
+ * (`gateway/src/http/routes/stt-stream-websocket.ts` requires an actor
29
+ * principal and refuses service tokens on the client-facing half).
30
+ */
31
+
32
+ import type {
33
+ StreamingTranscriber,
34
+ SttErrorCategory,
35
+ SttStreamServerEvent,
36
+ } from "../../stt/types.js";
37
+ import { getLogger } from "../../util/logger.js";
38
+ import { runWatchRetro } from "../../watch/watch-retro.js";
39
+ import {
40
+ WatchSessionManager,
41
+ type WatchSessionSummary,
42
+ } from "../../watch/watch-session-manager.js";
43
+
44
+ const log = getLogger("watch-stream");
45
+
46
+ /**
47
+ * How long a socket may go without an inbound frame before the session is torn
48
+ * down, matching `/v1/stt/stream`. A watch client streams capture continuously,
49
+ * so silence on the socket means the client is gone rather than that the user
50
+ * stopped talking, and a leaked session would hold the single manager slot
51
+ * against the next press of Watch.
52
+ */
53
+ const IDLE_TIMEOUT_MS = 60_000;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Frames
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Why a session ended badly. Provider failures keep the category
61
+ * `resolveStreamingTranscriber`'s stack already assigns them; `session-error`
62
+ * covers the reasons that are the watch session's own, such as a second socket
63
+ * arriving while one is running.
64
+ */
65
+ export type WatchStreamErrorCategory = SttErrorCategory | "session-error";
66
+
67
+ /**
68
+ * What the daemon sends back. Lifecycle only.
69
+ *
70
+ * `entry` is an acknowledgement that narration reached the session, carrying
71
+ * no text: it is what lets a client show that capture is live without drawing
72
+ * a transcript nobody is meant to read mid-session.
73
+ *
74
+ * `observation` is the same acknowledgement for the other half of a session,
75
+ * the screen reads the runtime takes around what the user says. It is a frame
76
+ * of its own rather than a discriminator on `entry` because the two report
77
+ * different facts with different failure modes: an `entry` is the narration
78
+ * the client itself just streamed coming back confirmed, while an
79
+ * `observation` is the only word a client ever gets that its screen was read
80
+ * at all. A client that treated them as one kind would have to re-derive that
81
+ * distinction from a field, and a client that knows nothing of the new frame
82
+ * ignores it, which is what makes this additive.
83
+ *
84
+ * Both are discrete events rather than states, and neither is emitted on a
85
+ * timer. A client can honestly draw the moment one arrives and nothing in
86
+ * between, which is the whole of what a watch session gives it to draw: the
87
+ * cadence is roughly three or four reads a minute (`MIN_OBSERVE_INTERVAL_MS`
88
+ * to `MAX_OBSERVE_INTERVAL_MS` in `watch-session-manager.ts`), so a
89
+ * client-side approximation of it would spend most of a session claiming a
90
+ * capture that is not happening.
91
+ */
92
+ export type WatchStreamServerFrame =
93
+ | { readonly type: "ready"; sessionId: string; conversationId: string }
94
+ | { readonly type: "entry" }
95
+ | { readonly type: "observation" }
96
+ | {
97
+ readonly type: "error";
98
+ category: WatchStreamErrorCategory;
99
+ message: string;
100
+ }
101
+ | { readonly type: "closed" };
102
+
103
+ /**
104
+ * Minimal socket surface, so the session can be driven by a test double
105
+ * instead of Bun's `ServerWebSocket`.
106
+ */
107
+ export interface WatchStreamSocket {
108
+ send(data: string): void;
109
+ close(code?: number, reason?: string): void;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Manager singleton
114
+ // ---------------------------------------------------------------------------
115
+
116
+ let sharedManager: WatchSessionManager | null = null;
117
+
118
+ /**
119
+ * The process-wide watch session manager.
120
+ *
121
+ * One instance because the manager owns one slot: it is driven by the one
122
+ * microphone the machine has, and a second manager would let two sessions
123
+ * interleave unrelated timelines. Lazily created so importing this module
124
+ * costs nothing until a socket arrives.
125
+ */
126
+ export function getWatchSessionManager(): WatchSessionManager {
127
+ sharedManager ??= new WatchSessionManager();
128
+ return sharedManager;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Session
133
+ // ---------------------------------------------------------------------------
134
+
135
+ type SessionState =
136
+ /** Constructed, waiting for the transcriber and the session slot. */
137
+ | "initializing"
138
+ /** Recording: audio frames are accepted and finals become narration. */
139
+ | "active"
140
+ /** The client sent `stop`; the provider is flushing its last finals. */
141
+ | "stopping"
142
+ /** Terminal. */
143
+ | "closed";
144
+
145
+ export interface WatchStreamSessionOptions {
146
+ /** MIME type of the audio the client streams. */
147
+ readonly mimeType: string;
148
+ /** Sample rate in Hz, threaded to the provider that wants one. */
149
+ readonly sampleRate?: number;
150
+ /** Adopt an existing conversation rather than minting one for the session. */
151
+ readonly conversationId?: string;
152
+ /** The desktop client to observe, when the actor has more than one. */
153
+ readonly clientId?: string;
154
+ /** Override the idle window for testing. */
155
+ readonly idleTimeoutMs?: number;
156
+ /** The manager the session drives. Defaults to the process-wide one. */
157
+ readonly manager?: WatchSessionManager;
158
+ /**
159
+ * Opens the provider stream. Defaults to `resolveStreamingTranscriber`,
160
+ * imported lazily so a caller that injects its own never pulls the provider
161
+ * stack into the module graph.
162
+ */
163
+ readonly resolveTranscriber?: () => Promise<StreamingTranscriber | null>;
164
+ /** Resolves the actor the session observes for. */
165
+ readonly resolveActorPrincipalId?: () => Promise<string | undefined>;
166
+ /**
167
+ * Runs the end-of-session retrospective. Defaults to {@link runWatchRetro}.
168
+ */
169
+ readonly runRetro?: (summary: WatchSessionSummary) => Promise<unknown>;
170
+ }
171
+
172
+ /**
173
+ * One watch session, from socket open to teardown.
174
+ *
175
+ * Created by the WebSocket `open` handler in `http-server.ts` and destroyed on
176
+ * `stop`, client disconnect, idle timeout, or runtime shutdown. Whichever of
177
+ * those arrives first, the manager slot is released exactly once.
178
+ */
179
+ export class WatchStreamSession {
180
+ private state: SessionState = "initializing";
181
+ private transcriber: StreamingTranscriber | null = null;
182
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
183
+ /**
184
+ * Whether this socket is the one holding the manager's slot. A socket that
185
+ * was turned away as busy must never stop the session that turned it away.
186
+ */
187
+ private ownsManagerSession = false;
188
+
189
+ private readonly ws: WatchStreamSocket;
190
+ private readonly options: WatchStreamSessionOptions;
191
+ private readonly manager: WatchSessionManager;
192
+ private readonly idleTimeoutMs: number;
193
+
194
+ constructor(ws: WatchStreamSocket, options: WatchStreamSessionOptions) {
195
+ this.ws = ws;
196
+ this.options = options;
197
+ this.manager = options.manager ?? getWatchSessionManager();
198
+ this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
199
+ }
200
+
201
+ /** Whether the session has reached its terminal state. */
202
+ get isClosed(): boolean {
203
+ return this.state === "closed";
204
+ }
205
+
206
+ // ── Startup ────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Resolve the actor, open the provider stream, and claim the session slot.
210
+ *
211
+ * The actor comes first because it is the binding everything else depends
212
+ * on: `observeHostScreen` reaches only that actor's own desktop clients, and
213
+ * a session started without one could record nothing but failures. Failing
214
+ * here sends `error` then `closed` rather than opening a session that cannot
215
+ * see anything.
216
+ */
217
+ async start(): Promise<void> {
218
+ if (this.state !== "initializing") {
219
+ log.warn(
220
+ { state: this.state },
221
+ "Watch stream start in non-initial state",
222
+ );
223
+ return;
224
+ }
225
+
226
+ if (watchIngressClosed) {
227
+ this.failStart(
228
+ "session-error",
229
+ "The assistant is shutting down and is not starting new watch sessions.",
230
+ 1001,
231
+ );
232
+ return;
233
+ }
234
+
235
+ try {
236
+ const sourceActorPrincipalId = await this.resolveActorPrincipalId();
237
+ if (this.isClosed) {
238
+ return;
239
+ }
240
+ if (!sourceActorPrincipalId) {
241
+ this.failStart(
242
+ "session-error",
243
+ "Watch could not resolve the actor to observe for. Sign in on this device and try again.",
244
+ 1008,
245
+ );
246
+ return;
247
+ }
248
+
249
+ const transcriber = await this.resolveTranscriber();
250
+
251
+ // The socket can close while either resolution is in flight. Read the
252
+ // terminal state through the getter so the compiler does not narrow it
253
+ // to the value it held before the await.
254
+ if (this.isClosed) {
255
+ stopQuietly(transcriber);
256
+ return;
257
+ }
258
+
259
+ if (!transcriber) {
260
+ this.failStart(
261
+ "provider-error",
262
+ "Watch needs a speech provider that supports streaming transcription.",
263
+ 1000,
264
+ );
265
+ return;
266
+ }
267
+
268
+ this.transcriber = transcriber;
269
+ await transcriber.start((event) => {
270
+ this.handleTranscriberEvent(event);
271
+ });
272
+
273
+ if (this.isClosed) {
274
+ stopQuietly(transcriber);
275
+ this.transcriber = null;
276
+ return;
277
+ }
278
+
279
+ const started = this.manager.start({
280
+ sourceActorPrincipalId,
281
+ onObservation: () => {
282
+ this.handleObservation();
283
+ },
284
+ ...(this.options.conversationId
285
+ ? { conversationId: this.options.conversationId }
286
+ : {}),
287
+ ...(this.options.clientId ? { clientId: this.options.clientId } : {}),
288
+ });
289
+ if (started.status !== "started") {
290
+ this.failStart(
291
+ "session-error",
292
+ started.status === "busy"
293
+ ? "A watch session is already running."
294
+ : started.reason,
295
+ 1000,
296
+ );
297
+ return;
298
+ }
299
+ this.ownsManagerSession = true;
300
+
301
+ this.state = "active";
302
+ this.resetIdleTimer();
303
+ this.sendFrame({
304
+ type: "ready",
305
+ sessionId: started.sessionId,
306
+ conversationId: started.conversationId,
307
+ });
308
+ log.info(
309
+ {
310
+ sessionId: started.sessionId,
311
+ conversationId: started.conversationId,
312
+ provider: transcriber.providerId,
313
+ },
314
+ "Watch stream session started",
315
+ );
316
+ } catch (err) {
317
+ const message = err instanceof Error ? err.message : String(err);
318
+ log.error({ error: message }, "Failed to start watch stream session");
319
+ this.failStart("provider-error", message, 1011);
320
+ }
321
+ }
322
+
323
+ // ── Inbound frames ─────────────────────────────────────────────────
324
+
325
+ /** Handle a text frame: a base64 `audio` event or `stop`. */
326
+ handleMessage(raw: string): void {
327
+ if (this.state === "closed") {
328
+ return;
329
+ }
330
+ this.resetIdleTimer();
331
+
332
+ let parsed: unknown;
333
+ try {
334
+ parsed = JSON.parse(raw);
335
+ } catch {
336
+ log.debug("Watch stream: dropped non-JSON text frame");
337
+ return;
338
+ }
339
+ if (!parsed || typeof parsed !== "object") {
340
+ return;
341
+ }
342
+
343
+ const event = parsed as {
344
+ type?: string;
345
+ audio?: string;
346
+ mimeType?: string;
347
+ };
348
+ switch (event.type) {
349
+ case "audio": {
350
+ if (this.state !== "active" || typeof event.audio !== "string") {
351
+ return;
352
+ }
353
+ this.transcriber?.sendAudio(
354
+ Buffer.from(event.audio, "base64"),
355
+ event.mimeType ?? this.options.mimeType,
356
+ );
357
+ return;
358
+ }
359
+ case "stop": {
360
+ this.handleStop();
361
+ return;
362
+ }
363
+ default: {
364
+ log.debug({ type: event.type }, "Watch stream: dropped unknown event");
365
+ return;
366
+ }
367
+ }
368
+ }
369
+
370
+ /** Handle a binary frame: raw audio bytes. */
371
+ handleBinaryAudio(data: Buffer | ArrayBuffer | Uint8Array): void {
372
+ if (this.state !== "active") {
373
+ return;
374
+ }
375
+ this.resetIdleTimer();
376
+
377
+ const buffer = Buffer.isBuffer(data)
378
+ ? data
379
+ : Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
380
+ this.transcriber?.sendAudio(buffer, this.options.mimeType);
381
+ }
382
+
383
+ /** The client disconnected, or the transport failed. */
384
+ handleClose(code: number, reason?: string): void {
385
+ if (this.state === "closed") {
386
+ return;
387
+ }
388
+ log.info({ code, reason }, "Watch stream WebSocket closed");
389
+ this.teardown();
390
+ }
391
+
392
+ /**
393
+ * Forcible teardown, for runtime shutdown.
394
+ *
395
+ * No retrospective. A retro is a full agent turn that runs for as long as the
396
+ * model takes, and the process behind it is on its way out: started here it
397
+ * would be killed partway through, leaving a half-written report in the
398
+ * thread. Skipping keeps the timeline, which is the whole of what the
399
+ * session recorded and outlives the daemon.
400
+ */
401
+ destroy(): void {
402
+ if (this.state === "closed") {
403
+ return;
404
+ }
405
+ log.info("Watch stream session destroyed");
406
+ this.teardown({ retrospective: false });
407
+ }
408
+
409
+ // ── Internals ──────────────────────────────────────────────────────
410
+
411
+ private async resolveActorPrincipalId(): Promise<string | undefined> {
412
+ if (this.options.resolveActorPrincipalId) {
413
+ return this.options.resolveActorPrincipalId();
414
+ }
415
+ return resolveWatchActorPrincipalId();
416
+ }
417
+
418
+ private async resolveTranscriber(): Promise<StreamingTranscriber | null> {
419
+ if (this.options.resolveTranscriber) {
420
+ return this.options.resolveTranscriber();
421
+ }
422
+ const { resolveStreamingTranscriber } =
423
+ await import("../../providers/speech-to-text/resolve.js");
424
+ return resolveStreamingTranscriber(
425
+ this.options.sampleRate !== undefined
426
+ ? { sampleRate: this.options.sampleRate }
427
+ : {},
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Report why the session never opened, then close. The teardown between the
433
+ * two stops a transcriber that opened before the failing step, and emits the
434
+ * terminal `closed` frame.
435
+ */
436
+ private failStart(
437
+ category: WatchStreamErrorCategory,
438
+ message: string,
439
+ closeCode: number,
440
+ ): void {
441
+ this.sendFrame({ type: "error", category, message });
442
+ this.teardown();
443
+ this.closeSocket(closeCode, "watch session start failed");
444
+ }
445
+
446
+ /**
447
+ * The client finished narrating. The provider may still emit finals after
448
+ * `stop()`, so the session waits for the provider's `closed` rather than
449
+ * tearing down here.
450
+ */
451
+ private handleStop(): void {
452
+ if (this.state !== "active") {
453
+ return;
454
+ }
455
+ this.state = "stopping";
456
+ this.clearIdleTimer();
457
+
458
+ try {
459
+ this.transcriber?.stop();
460
+ } catch (err) {
461
+ const message = err instanceof Error ? err.message : String(err);
462
+ log.error({ error: message }, "Error stopping the watch transcriber");
463
+ this.sendFrame({ type: "error", category: "provider-error", message });
464
+ this.teardown();
465
+ this.closeSocket(1011, "stop failed");
466
+ }
467
+ }
468
+
469
+ /**
470
+ * A screen read landed on the timeline, so tell the client.
471
+ *
472
+ * The manager only calls this for a read that came back and was kept, so
473
+ * everything the session does with the news is send it: a failed, timed-out,
474
+ * or cancelled read never reaches here (see `WatchSessionStartOptions`).
475
+ *
476
+ * The terminal check is the socket's own. A read dispatched moments before
477
+ * teardown is dropped by the manager's `stopped` guard, so this is guarding
478
+ * the narrower case of a listener that outlived the session it was passed
479
+ * with, and it keeps the frame order the client's contract: nothing after
480
+ * `closed`.
481
+ */
482
+ private handleObservation(): void {
483
+ if (this.state === "closed") {
484
+ return;
485
+ }
486
+ this.sendFrame({ type: "observation" });
487
+ }
488
+
489
+ private handleTranscriberEvent(event: SttStreamServerEvent): void {
490
+ if (this.state === "closed") {
491
+ return;
492
+ }
493
+
494
+ if (event.type === "turn-start") {
495
+ // Onset, not text. Observing here catches the screen the user is about
496
+ // to describe rather than the one their sentence left behind; the
497
+ // narration itself is filed by the `final` below. Fire and forget for
498
+ // the same reason as that one, and no `entry` frame is sent because no
499
+ // narration was appended; the read this triggers announces itself
500
+ // through `handleObservation` if it lands.
501
+ void this.manager.handleNarrationStart().catch((err: unknown) => {
502
+ log.warn({ err }, "Watch narration-start observation threw");
503
+ });
504
+ return;
505
+ }
506
+
507
+ if (event.type === "final") {
508
+ const text = event.text.trim();
509
+ if (!text) {
510
+ return;
511
+ }
512
+ // Fire and forget: the narration is filed synchronously inside
513
+ // `handleNarrationFinal`, and what remains is the screen read, which the
514
+ // manager already owns the failure handling for.
515
+ void this.manager.handleNarrationFinal(text).catch((err: unknown) => {
516
+ log.warn({ err }, "Watch narration append threw");
517
+ });
518
+ this.sendFrame({ type: "entry" });
519
+ return;
520
+ }
521
+
522
+ if (event.type === "error") {
523
+ this.sendFrame({
524
+ type: "error",
525
+ category: event.category,
526
+ message: event.message,
527
+ });
528
+ return;
529
+ }
530
+
531
+ if (event.type === "closed") {
532
+ this.teardown();
533
+ this.closeSocket(1000, "session complete");
534
+ }
535
+ }
536
+
537
+ // ── Idle timer ─────────────────────────────────────────────────────
538
+
539
+ private resetIdleTimer(): void {
540
+ this.clearIdleTimer();
541
+ if (this.state === "closed" || this.state === "stopping") {
542
+ return;
543
+ }
544
+
545
+ this.idleTimer = setTimeout(() => {
546
+ if (this.state === "closed") {
547
+ return;
548
+ }
549
+ log.warn("Watch stream session idle timeout");
550
+ this.sendFrame({
551
+ type: "error",
552
+ category: "timeout",
553
+ message: "The watch session timed out because the client went quiet.",
554
+ });
555
+ this.teardown();
556
+ this.closeSocket(1000, "idle timeout");
557
+ }, this.idleTimeoutMs);
558
+ }
559
+
560
+ private clearIdleTimer(): void {
561
+ if (this.idleTimer !== null) {
562
+ clearTimeout(this.idleTimer);
563
+ this.idleTimer = null;
564
+ }
565
+ }
566
+
567
+ // ── Teardown ───────────────────────────────────────────────────────
568
+
569
+ /**
570
+ * Release everything the session holds and send the terminal `closed` frame.
571
+ * Idempotent: the close handler, the idle timer, and the provider's own
572
+ * `closed` all land here, and only the first one does any work.
573
+ */
574
+ private teardown(
575
+ options: { retrospective: boolean } = { retrospective: true },
576
+ ): void {
577
+ if (this.state === "closed") {
578
+ return;
579
+ }
580
+ this.state = "closed";
581
+ this.clearIdleTimer();
582
+
583
+ if (this.transcriber) {
584
+ stopQuietly(this.transcriber);
585
+ this.transcriber = null;
586
+ }
587
+
588
+ if (this.ownsManagerSession) {
589
+ this.ownsManagerSession = false;
590
+ // A screen read still in flight is dropped by the manager's `stopped`
591
+ // guard rather than awaited. Narration itself survives, because
592
+ // `handleNarrationFinal` files it synchronously before it observes, so
593
+ // what is lost is at most one trailing frame of a session that is over.
594
+ const summary = this.manager.stop();
595
+ if (summary) {
596
+ log.info(
597
+ {
598
+ sessionId: summary.sessionId,
599
+ conversationId: summary.conversationId,
600
+ entryCount: summary.entryCount,
601
+ durationMs: summary.durationMs,
602
+ },
603
+ "Watch session ended",
604
+ );
605
+ if (options.retrospective) {
606
+ this.startRetrospective(summary);
607
+ } else {
608
+ log.info(
609
+ { sessionId: summary.sessionId },
610
+ "Watch session ended during shutdown; skipping the retrospective",
611
+ );
612
+ }
613
+ }
614
+ }
615
+
616
+ this.sendFrame({ type: "closed" });
617
+ }
618
+
619
+ /**
620
+ * Start the retrospective and register it so shutdown can wait on it.
621
+ *
622
+ * The turn is a conversation that outlives the socket by minutes, so
623
+ * teardown starts it rather than blocking on it, and it owns its own
624
+ * failures. Registration is what stops a stop-then-quit from killing a turn
625
+ * mid-generation: {@link drainWatchRetros} gives one already in flight a
626
+ * bounded chance to finish.
627
+ */
628
+ private startRetrospective(summary: WatchSessionSummary): void {
629
+ const runRetro = this.options.runRetro ?? runWatchRetro;
630
+ const pending = runRetro(summary).catch((err: unknown) => {
631
+ log.warn(
632
+ { err, sessionId: summary.sessionId },
633
+ "Watch retrospective threw",
634
+ );
635
+ });
636
+ inFlightWatchRetros.add(pending);
637
+ void pending.finally(() => {
638
+ inFlightWatchRetros.delete(pending);
639
+ });
640
+ }
641
+
642
+ private sendFrame(frame: WatchStreamServerFrame): void {
643
+ try {
644
+ this.ws.send(JSON.stringify(frame));
645
+ } catch (err) {
646
+ log.debug({ err }, "Watch stream: failed to send a frame");
647
+ }
648
+ }
649
+
650
+ private closeSocket(code: number, reason: string): void {
651
+ try {
652
+ this.ws.close(code, reason);
653
+ } catch {
654
+ // Already closed.
655
+ }
656
+ }
657
+ }
658
+
659
+ /** Stop a transcriber that may already be gone. */
660
+ function stopQuietly(transcriber: StreamingTranscriber | null): void {
661
+ if (!transcriber) {
662
+ return;
663
+ }
664
+ try {
665
+ transcriber.stop();
666
+ } catch {
667
+ // Best effort.
668
+ }
669
+ }
670
+
671
+ /**
672
+ * The actor a watch session observes for: the vellum guardian bound to this
673
+ * daemon, read from the gateway-owned binding.
674
+ *
675
+ * The principal comes from that binding rather than from anything the request
676
+ * carries, because the request cannot carry a trustworthy one. The gateway
677
+ * authenticates the downstream client's edge JWT and then dials the runtime on
678
+ * a fresh socket bearing only its own service token
679
+ * (`gateway/src/http/routes/stt-stream-websocket.ts`), so an actor claim on
680
+ * the upgrade is never the gateway's word about who the client is. Honouring
681
+ * one would let any caller holding the service token bind a session, and the
682
+ * screen reads it drives, to somebody else's principal.
683
+ *
684
+ * It is the same binding `resolveActorPrincipalIdForLocalGuardian` falls
685
+ * through to and the same one `live-voice-session.ts` stamps its turns with,
686
+ * so a watch session observes the principal the host-proxy result routes match
687
+ * a desktop client against.
688
+ *
689
+ * The read deliberately bypasses the guardian-delivery cache. That cache keeps
690
+ * a successful read that found no binding, and a gateway-side binding write
691
+ * does not invalidate it, so a guardian bound after the daemon cached an empty
692
+ * answer would leave every Watch press failing the unresolvable-principal path
693
+ * until the TTL lapsed. That is the order of events on a first run, and it
694
+ * fails looking like a broken feature rather than one that is not ready yet. A
695
+ * session starts only when a person asks for one, so a fresh read costs
696
+ * nothing worth weighing against that.
697
+ */
698
+ export async function resolveWatchActorPrincipalId(): Promise<
699
+ string | undefined
700
+ > {
701
+ const { findLocalGuardianPrincipalId } =
702
+ await import("../local-actor-identity.js");
703
+ return findLocalGuardianPrincipalId({ forceRefresh: true });
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Active session registry
708
+ // ---------------------------------------------------------------------------
709
+
710
+ /**
711
+ * Open watch sessions keyed by socket session id, so runtime shutdown can tear
712
+ * them all down deterministically. Mirrors `activeSttStreamSessions`.
713
+ */
714
+ export const activeWatchStreamSessions = new Map<string, WatchStreamSession>();
715
+
716
+ /**
717
+ * Retrospectives that have started and not yet settled.
718
+ *
719
+ * A retro is dispatched from a teardown that cannot wait on it, so without a
720
+ * handle a socket closing seconds before shutdown leaves a turn running
721
+ * against a database that is about to be closed underneath it.
722
+ */
723
+ const inFlightWatchRetros = new Set<Promise<unknown>>();
724
+
725
+ /**
726
+ * Whether new watch sessions are being refused.
727
+ *
728
+ * Shutdown tears down the sessions it can see and then waits on the
729
+ * retrospectives they left running, and the Bun server keeps accepting
730
+ * connections until well after both. Without this latch a socket that opens
731
+ * and closes inside that window registers a retrospective nobody is waiting
732
+ * on, which is the turn the drain exists to protect.
733
+ *
734
+ * A latch here rather than a shared one because the daemon has no shutdown
735
+ * state a route can read: `shutdown-handlers.ts` keeps its flag module-private
736
+ * and process-wide, and the readiness module tracks migrations rather than
737
+ * teardown.
738
+ */
739
+ let watchIngressClosed = false;
740
+
741
+ /**
742
+ * Refuse new watch sessions, the first step of shutting the surface down.
743
+ *
744
+ * Separate from tearing the open sessions down so the order can be ingress
745
+ * first, sessions second, retrospectives last. A session that arrives after
746
+ * this fails its start with a clean error frame rather than opening and being
747
+ * killed moments later.
748
+ */
749
+ export function closeWatchIngress(): void {
750
+ watchIngressClosed = true;
751
+ }
752
+
753
+ /** Accept watch sessions again. For tests, which share a module instance. */
754
+ export function reopenWatchIngressForTest(): void {
755
+ watchIngressClosed = false;
756
+ }
757
+
758
+ /**
759
+ * Longest shutdown waits for retrospectives already under way.
760
+ *
761
+ * Shutdown's other awaited step is releasing the live-voice session, which is
762
+ * a handful of socket closes, so there is no established budget to borrow. A
763
+ * retro is a model call and can legitimately take longer than any shutdown
764
+ * should, which is why one is never started during shutdown; this bound is for
765
+ * the turn that was already running when the user quit, and it is short enough
766
+ * that quitting stays a quick action.
767
+ */
768
+ const RETRO_DRAIN_TIMEOUT_MS = 5_000;
769
+
770
+ /**
771
+ * Wait for in-flight retrospectives, up to {@link RETRO_DRAIN_TIMEOUT_MS}, and
772
+ * report how many were still running when the wait ended.
773
+ *
774
+ * Resolves rather than rejects on the timeout: a retro that is still going is
775
+ * a turn that will be cut off, which is worth a log line and never a reason to
776
+ * fail the shutdown that is cutting it off. A settled one is forgotten, so the
777
+ * registry tracks what is running rather than everything that ever ran.
778
+ */
779
+ export async function drainWatchRetros(
780
+ timeoutMs: number = RETRO_DRAIN_TIMEOUT_MS,
781
+ ): Promise<number> {
782
+ if (inFlightWatchRetros.size === 0) {
783
+ return 0;
784
+ }
785
+ log.info(
786
+ { count: inFlightWatchRetros.size },
787
+ "Waiting for watch retrospectives to settle",
788
+ );
789
+ let timer: ReturnType<typeof setTimeout> | undefined;
790
+ const deadline = new Promise<void>((resolve) => {
791
+ timer = setTimeout(resolve, timeoutMs);
792
+ timer.unref?.();
793
+ });
794
+ await Promise.race([
795
+ Promise.allSettled([...inFlightWatchRetros]).then(() => undefined),
796
+ deadline,
797
+ ]);
798
+ clearTimeout(timer);
799
+ const unsettled = inFlightWatchRetros.size;
800
+ if (unsettled > 0) {
801
+ log.warn(
802
+ { count: unsettled },
803
+ "Shutting down with watch retrospectives still running",
804
+ );
805
+ }
806
+ return unsettled;
807
+ }