@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,575 @@
1
+ /**
2
+ * The live half of a watch session: the user narrates a task while they work,
3
+ * and this decides when to read their screen and what of it to keep.
4
+ *
5
+ * One session at a time, the way `live-voice-session-manager.ts` holds one
6
+ * call. Both are driven by a microphone the machine has exactly one of, and
7
+ * both own a slot rather than a set: a second session would compete for the
8
+ * same audio and interleave two unrelated timelines into one store.
9
+ *
10
+ * The manager owns three decisions the pieces below it deliberately do not
11
+ * make. When to observe, which is a cadence question and belongs to whoever
12
+ * hears the narration. Which observations are worth a stored frame, which
13
+ * `watch-timeline` refuses to infer because the payload it is handed always
14
+ * carries one. And when the session is over, which it answers with a handle
15
+ * rather than by acting: the retrospective is a conversational turn, and a
16
+ * session that ends because the daemon is shutting down has nowhere to run it.
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ import {
22
+ createConversation,
23
+ getConversation,
24
+ } from "../persistence/conversation-crud.js";
25
+ import {
26
+ type HostObservationFields,
27
+ observeHostScreen,
28
+ } from "../runtime/host-observe.js";
29
+ import { getLogger } from "../util/logger.js";
30
+ import {
31
+ appendNarration,
32
+ appendObservation,
33
+ type WatchAppendResult,
34
+ } from "./watch-timeline.js";
35
+
36
+ const log = getLogger("watch-session-manager");
37
+
38
+ /**
39
+ * Shortest gap between two observations.
40
+ *
41
+ * Observation is triggered by narration, not by a poll. Speech is the moment
42
+ * the user is saying what they are doing, which is exactly the moment their
43
+ * screen is worth recording. A poll is both wasteful and lossy: most ticks
44
+ * land mid-gesture on a screen nobody described, and the change that matters
45
+ * lands between two of them. The subscription that would make polling
46
+ * unnecessary does not exist either: the mac helper's `cu.perform` is strictly
47
+ * request/response (`HostCuExecutor.swift`), with no channel for the host to
48
+ * push an accessibility change of its own.
49
+ *
50
+ * Every observation costs a full accessibility enumeration plus a JPEG over
51
+ * the wire, so this floor collapses a burst of triggers into one record while
52
+ * staying shorter than any UI step a person pauses to narrate.
53
+ *
54
+ * Measured on real sessions, narration arrives roughly every fifteen seconds
55
+ * rather than every second or two: people narrate in bursts and fall silent
56
+ * while they do the thing they just described. So this floor is rarely the
57
+ * binding constraint, and raising the rate it permits buys nothing. Coverage
58
+ * comes from how many triggers fire, not from how fast they are allowed to —
59
+ * which is what the onset trigger
60
+ * ({@link WatchSessionManager.handleNarrationStart}) and the opening
61
+ * observation in {@link WatchSessionManager.start} are for.
62
+ */
63
+ const MIN_OBSERVE_INTERVAL_MS = 5_000;
64
+
65
+ /**
66
+ * Longest a session goes without an observation.
67
+ *
68
+ * Narration is the trigger, so silent work would otherwise be invisible: the
69
+ * user drags a file, waits on a build, or reads for a minute, and the timeline
70
+ * jumps from what they said before to what they said after with the work
71
+ * itself missing. This is the ceiling on that gap.
72
+ *
73
+ * It is a ceiling and not a poll. The deadline runs from the moment the last
74
+ * observation was dispatched, so the wait a slow read spends counts against the
75
+ * ceiling rather than adding to it. It fires only in a stretch where narration
76
+ * produced none, and a talkative session never reaches it.
77
+ *
78
+ * Sized against how far apart narration actually lands, which measurement puts
79
+ * at roughly fifteen seconds. Matching the two means a silent stretch is
80
+ * covered at about the rate a narrated one is. Set much longer and this stops
81
+ * being a backstop and becomes the dominant trigger, which is worse than it
82
+ * sounds: the gap it leaves falls at the *start* of a session, where the user
83
+ * is opening the thing they are about to demonstrate and the retrospective
84
+ * has no other account of where they began.
85
+ */
86
+ const MAX_OBSERVE_INTERVAL_MS = 15_000;
87
+
88
+ /**
89
+ * How long one observation may take before the session gives up on it.
90
+ *
91
+ * Shorter than {@link MAX_OBSERVE_INTERVAL_MS} so a stalled request cannot
92
+ * outlive the cadence slot it belongs to. `observeHostScreen` defaults to 30s,
93
+ * which is a reasonable wait for a caller with a turn to block on it, and too
94
+ * long for one recording a screen that has since moved on.
95
+ */
96
+ const OBSERVE_TIMEOUT_MS = 10_000;
97
+
98
+ /**
99
+ * The sentence `AXTreeDiff` writes in place of a diff when the window it
100
+ * compared was replaced wholesale rather than edited (`AXTreeDiff.swift`).
101
+ */
102
+ const WHOLE_WINDOW_REPLACEMENT_MARKER = "Page navigated";
103
+
104
+ /**
105
+ * Title carried by a conversation a session mints for itself.
106
+ *
107
+ * Persisted, and read by a person: a session that produces a retrospective
108
+ * surfaces its conversation into the ordinary list, so this is the name of a
109
+ * thread the user goes looking for. It follows the control they pressed.
110
+ *
111
+ * Unlike {@link WATCH_CONVERSATION_SOURCE} below, which is a discriminator
112
+ * nothing displays, so it keeps the word it was frozen with. Existing threads
113
+ * keep the title they were minted with; nothing rewrites them.
114
+ */
115
+ const TEACH_CONVERSATION_TITLE = "Teach session";
116
+
117
+ // FROZEN: persisted `conversations.source` value. Never rename it.
118
+ const WATCH_CONVERSATION_SOURCE = "watch";
119
+
120
+ /**
121
+ * Whether an observation is worth a stored frame.
122
+ *
123
+ * Text first. An accessibility tree describes the screen in a form the
124
+ * retrospective reads directly and costs a fraction of a JPEG to keep. That
125
+ * the payload carries a screenshot is no signal at all: the mac helper
126
+ * captures one on every observe with no opt-out (`HostCuExecutor.swift`), so
127
+ * it is always true. Two shapes make the text thin enough that the pixels
128
+ * become the better record:
129
+ *
130
+ * - No tree. The helper fell back to a bare screenshot because accessibility
131
+ * enumeration found no focused window, the ordinary result for an app that
132
+ * exposes nothing. The frame is the only account of that moment there is.
133
+ * - A whole-window replacement. The window the diff was computed against is
134
+ * gone, so what the user is looking at now is unrelated to anything already
135
+ * in the timeline.
136
+ */
137
+ function shouldAttachScreenshot(observation: HostObservationFields): boolean {
138
+ if (!observation.axTree) {
139
+ return true;
140
+ }
141
+ return observation.axDiff?.includes(WHOLE_WINDOW_REPLACEMENT_MARKER) === true;
142
+ }
143
+
144
+ /** What a finished session leaves behind for the retrospective to read. */
145
+ export interface WatchSessionSummary {
146
+ readonly sessionId: string;
147
+ readonly conversationId: string;
148
+ /** Timeline entries the session persisted, narrations and observations. */
149
+ readonly entryCount: number;
150
+ readonly durationMs: number;
151
+ }
152
+
153
+ export interface WatchSessionStartOptions {
154
+ /**
155
+ * Principal id of the actor the session observes on behalf of, the same
156
+ * binding every `HostCuProxy` caller carries. `observeHostScreen` matches
157
+ * the target desktop client against it and fails closed without one.
158
+ */
159
+ readonly sourceActorPrincipalId: string;
160
+ /**
161
+ * Adopt an existing conversation instead of minting one. The row must
162
+ * already exist.
163
+ */
164
+ readonly conversationId?: string;
165
+ /**
166
+ * The desktop client to observe. Required when the actor has more than one
167
+ * connected, because default selection resolves their single `host_cu`
168
+ * client and returns an ambiguity error otherwise.
169
+ */
170
+ readonly clientId?: string;
171
+ /**
172
+ * Called once for each screen read that landed on the timeline, so whoever
173
+ * started the session can tell the user their screen was just read.
174
+ *
175
+ * **It fires on the observation landing, never on the request going out.**
176
+ * A dispatch is a promise, and this session has three ways of breaking one.
177
+ * The host answers `ok: false`, which is every failure it has including a
178
+ * read that outran {@link OBSERVE_TIMEOUT_MS}. The request throws. Or the
179
+ * session ends underneath a read still in flight and the `stopped` guard
180
+ * drops what comes back. An indicator driven from dispatch would draw a
181
+ * capture in all three, which is the one thing a capture indicator may not
182
+ * do. Fired from the single point past every one of those checks, so a
183
+ * failure mode added later is silent here by default rather than loud and
184
+ * wrong.
185
+ *
186
+ * Landing rather than merely returning, for the same reason. A read the
187
+ * store refused (its conversation is gone, or the payload carried nothing)
188
+ * left no record of the screen behind, and there is nothing to confirm.
189
+ *
190
+ * Scoped to the session it was passed with: the manager drops it along with
191
+ * the session on {@link WatchSessionManager.stop}, so a listener cannot
192
+ * outlive what it is reporting on. It owns its own failures; a throw is
193
+ * logged and the session carries on watching.
194
+ */
195
+ readonly onObservation?: () => void;
196
+ }
197
+
198
+ export type WatchSessionStartResult =
199
+ | {
200
+ readonly status: "started";
201
+ readonly sessionId: string;
202
+ readonly conversationId: string;
203
+ }
204
+ | {
205
+ readonly status: "busy";
206
+ readonly sessionId: string;
207
+ readonly conversationId: string;
208
+ }
209
+ | { readonly status: "failed"; readonly reason: string };
210
+
211
+ export interface WatchSessionManagerOptions {
212
+ /** Reads the screen. Defaults to {@link observeHostScreen}. */
213
+ readonly observe?: typeof observeHostScreen;
214
+ /** Clock the timeline's `atMs` offsets and the rate limit are measured on. */
215
+ readonly now?: () => number;
216
+ readonly createSessionId?: () => string;
217
+ }
218
+
219
+ interface ActiveWatchSession {
220
+ readonly sessionId: string;
221
+ readonly conversationId: string;
222
+ readonly sourceActorPrincipalId: string;
223
+ readonly clientId: string | undefined;
224
+ readonly onObservation: (() => void) | undefined;
225
+ readonly startedAtMs: number;
226
+ entryCount: number;
227
+ /**
228
+ * When the last observation was dispatched, the anchor both the rate limit
229
+ * and the idle ceiling measure from. Negative infinity until the first one,
230
+ * so a session observes on its opening narration rather than spending its
231
+ * first interval blind.
232
+ */
233
+ lastObserveAtMs: number;
234
+ observing: boolean;
235
+ stopped: boolean;
236
+ idleTimer: ReturnType<typeof setTimeout> | null;
237
+ }
238
+
239
+ export class WatchSessionManager {
240
+ private readonly observe: typeof observeHostScreen;
241
+ private readonly now: () => number;
242
+ private readonly createSessionId: () => string;
243
+ private session: ActiveWatchSession | null = null;
244
+
245
+ constructor(options: WatchSessionManagerOptions = {}) {
246
+ this.observe = options.observe ?? observeHostScreen;
247
+ this.now = options.now ?? Date.now;
248
+ this.createSessionId = options.createSessionId ?? randomUUID;
249
+ }
250
+
251
+ /**
252
+ * Whether a session is running, optionally for one specific conversation.
253
+ * The toggle that starts and ends a session reads this to know which edge a
254
+ * press is.
255
+ */
256
+ isActive(conversationId?: string): boolean {
257
+ const session = this.session;
258
+ if (session === null) {
259
+ return false;
260
+ }
261
+ return (
262
+ conversationId === undefined || session.conversationId === conversationId
263
+ );
264
+ }
265
+
266
+ get activeSessionId(): string | null {
267
+ return this.session?.sessionId ?? null;
268
+ }
269
+
270
+ start(options: WatchSessionStartOptions): WatchSessionStartResult {
271
+ const existing = this.session;
272
+ if (existing !== null) {
273
+ return {
274
+ status: "busy",
275
+ sessionId: existing.sessionId,
276
+ conversationId: existing.conversationId,
277
+ };
278
+ }
279
+
280
+ // The same fail-closed stance `observeHostScreen` takes, applied before a
281
+ // session exists rather than once per observation: a session without an
282
+ // actor could reach no client and would record nothing but failures.
283
+ if (!options.sourceActorPrincipalId) {
284
+ return {
285
+ status: "failed",
286
+ reason: "A watch session requires the actor principal it observes for.",
287
+ };
288
+ }
289
+
290
+ const conversationId = this.resolveConversationId(options.conversationId);
291
+ if (conversationId === null) {
292
+ return {
293
+ status: "failed",
294
+ reason: `Conversation "${options.conversationId}" does not exist.`,
295
+ };
296
+ }
297
+
298
+ const session: ActiveWatchSession = {
299
+ sessionId: this.createSessionId(),
300
+ conversationId,
301
+ sourceActorPrincipalId: options.sourceActorPrincipalId,
302
+ clientId: options.clientId,
303
+ onObservation: options.onObservation,
304
+ startedAtMs: this.now(),
305
+ entryCount: 0,
306
+ lastObserveAtMs: Number.NEGATIVE_INFINITY,
307
+ observing: false,
308
+ stopped: false,
309
+ idleTimer: null,
310
+ };
311
+ this.session = session;
312
+ // Read the screen the demonstration begins from, rather than waiting for
313
+ // the first trigger. The opening state is the cheapest context there is
314
+ // and the most expensive to be missing: a demonstration starts with the
315
+ // user already somewhere, and a retrospective that never saw where cannot
316
+ // tell whether the first step was navigating there or working there —
317
+ // it reports the ambiguity instead of the task.
318
+ //
319
+ // Fire and forget, like the idle timer's own dispatch: `observeNow` owns
320
+ // its failures, and a start must not wait on a screen read. It also arms
321
+ // the ceiling on the way out through `scheduleIdleObservation`, which is
322
+ // why nothing arms it here.
323
+ void this.observeNow(session);
324
+
325
+ return {
326
+ status: "started",
327
+ sessionId: session.sessionId,
328
+ conversationId,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Observe because the user has started speaking, if the cadence allows it.
334
+ * The entry point the streaming transcript calls on `turn-start`.
335
+ *
336
+ * Speech onset beats the final by however long the sentence takes, and the
337
+ * screen it lands on is the one being described rather than the one after.
338
+ * A person says "now I drag it to the Trash" and then drags it, so the final
339
+ * arrives with the gesture already finished: the state that explains the
340
+ * words is the one that was on screen when they began.
341
+ *
342
+ * Files no entry of its own. There is no text yet at onset, and the final
343
+ * that follows appends the narration; an entry here would be a second,
344
+ * emptier record of one utterance.
345
+ */
346
+ async handleNarrationStart(): Promise<void> {
347
+ const session = this.session;
348
+ if (session === null) {
349
+ return;
350
+ }
351
+ if (this.now() - session.lastObserveAtMs < MIN_OBSERVE_INTERVAL_MS) {
352
+ return;
353
+ }
354
+ await this.observeNow(session);
355
+ }
356
+
357
+ /**
358
+ * Record what the user just said and observe the screen if the cadence
359
+ * allows it. The entry point the streaming transcript calls on every final.
360
+ */
361
+ async handleNarrationFinal(text: string): Promise<void> {
362
+ const session = this.session;
363
+ if (session === null) {
364
+ return;
365
+ }
366
+
367
+ const nowMs = this.now();
368
+ this.recordAppend(
369
+ session,
370
+ appendNarration(session.sessionId, {
371
+ conversationId: session.conversationId,
372
+ text,
373
+ atMs: nowMs - session.startedAtMs,
374
+ }),
375
+ );
376
+
377
+ if (nowMs - session.lastObserveAtMs < MIN_OBSERVE_INTERVAL_MS) {
378
+ return;
379
+ }
380
+ await this.observeNow(session);
381
+ }
382
+
383
+ /**
384
+ * End the session and hand back what it recorded.
385
+ *
386
+ * Returns null when nothing is running, so a second stop and a stop that
387
+ * races a client disconnect are both no-ops rather than a second handle for
388
+ * the same session.
389
+ */
390
+ stop(): WatchSessionSummary | null {
391
+ const session = this.session;
392
+ this.session = null;
393
+ if (session === null) {
394
+ return null;
395
+ }
396
+
397
+ session.stopped = true;
398
+ this.clearIdleTimer(session);
399
+
400
+ return {
401
+ sessionId: session.sessionId,
402
+ conversationId: session.conversationId,
403
+ entryCount: session.entryCount,
404
+ durationMs: Math.max(0, this.now() - session.startedAtMs),
405
+ };
406
+ }
407
+
408
+ /**
409
+ * The conversation a session's timeline is keyed on.
410
+ *
411
+ * `background` so the thread stays out of the sidebar while the session
412
+ * runs: nothing is said in it, no turn runs, and its only reader is the
413
+ * retrospective that comes after. A caller-supplied id is adopted only when
414
+ * its row already exists, so a session never mints a conversation under an
415
+ * id it was handed.
416
+ */
417
+ private resolveConversationId(
418
+ conversationId: string | undefined,
419
+ ): string | null {
420
+ if (conversationId !== undefined) {
421
+ return getConversation(conversationId) === null ? null : conversationId;
422
+ }
423
+ return createConversation({
424
+ title: TEACH_CONVERSATION_TITLE,
425
+ conversationType: "background",
426
+ source: WATCH_CONVERSATION_SOURCE,
427
+ origin: "vellum",
428
+ }).id;
429
+ }
430
+
431
+ /**
432
+ * Read the screen once and file the result under the moment the request went
433
+ * out.
434
+ *
435
+ * The rate limit and the idle ceiling are both anchored at dispatch rather
436
+ * than at completion, so a slow read neither shortens the gap before the next
437
+ * one nor pushes it out, and a read still in flight turns away the finals
438
+ * that arrive during it.
439
+ */
440
+ private async observeNow(session: ActiveWatchSession): Promise<void> {
441
+ if (session.stopped) {
442
+ return;
443
+ }
444
+ if (session.observing) {
445
+ // The read in flight stands in for this one: it rearms the ceiling
446
+ // against its own dispatch when it settles, which is already due when the
447
+ // read outlasted the interval. The tick is deferred, not dropped.
448
+ return;
449
+ }
450
+ session.observing = true;
451
+ session.lastObserveAtMs = this.now();
452
+ const atMs = session.lastObserveAtMs - session.startedAtMs;
453
+
454
+ try {
455
+ const observation = await this.observe({
456
+ sourceActorPrincipalId: session.sourceActorPrincipalId,
457
+ timeoutMs: OBSERVE_TIMEOUT_MS,
458
+ ...(session.clientId ? { clientId: session.clientId } : {}),
459
+ });
460
+ if (session.stopped) {
461
+ return;
462
+ }
463
+ if (!observation.ok) {
464
+ // A session outlives a screen it could not read. The desktop client
465
+ // may be busy, asleep, or briefly disconnected, and the narration
466
+ // still arriving is worth keeping either way.
467
+ log.debug(
468
+ { sessionId: session.sessionId, reason: observation.reason },
469
+ "Watch observation failed",
470
+ );
471
+ return;
472
+ }
473
+ const appended = appendObservation(session.sessionId, {
474
+ conversationId: session.conversationId,
475
+ observation,
476
+ atMs,
477
+ attachScreenshot: shouldAttachScreenshot(observation),
478
+ });
479
+ this.recordAppend(session, appended);
480
+ if (appended.ok) {
481
+ this.announceObservation(session);
482
+ }
483
+ } catch (err) {
484
+ // Nothing on this path is meant to throw, and the idle timer has no
485
+ // caller to hand a rejection to, so an unexpected one ends the
486
+ // observation rather than the process.
487
+ log.warn(
488
+ { err, sessionId: session.sessionId },
489
+ "Watch observation threw",
490
+ );
491
+ } finally {
492
+ session.observing = false;
493
+ this.scheduleIdleObservation(session);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Arm the ceiling on the gap between observations.
499
+ *
500
+ * The deadline is {@link MAX_OBSERVE_INTERVAL_MS} past the last dispatch, so
501
+ * rearming it after a slow read leaves only what remains of that interval and
502
+ * a read that outlasts the interval leaves nothing: the next observation goes
503
+ * out as soon as it settles. Rearmed rather than run as an interval, so it
504
+ * never queues behind itself.
505
+ */
506
+ private scheduleIdleObservation(session: ActiveWatchSession): void {
507
+ this.clearIdleTimer(session);
508
+ if (session.stopped) {
509
+ return;
510
+ }
511
+ // Before the first observation the ceiling runs from the session's start,
512
+ // the last moment its screen was accounted for.
513
+ const anchorMs = Math.max(session.lastObserveAtMs, session.startedAtMs);
514
+ const delayMs = Math.max(
515
+ 0,
516
+ anchorMs + MAX_OBSERVE_INTERVAL_MS - this.now(),
517
+ );
518
+ const timer = setTimeout(() => {
519
+ session.idleTimer = null;
520
+ void this.observeNow(session);
521
+ }, delayMs);
522
+ // A watch session is not a reason to hold the process open.
523
+ timer.unref?.();
524
+ session.idleTimer = timer;
525
+ }
526
+
527
+ private clearIdleTimer(session: ActiveWatchSession): void {
528
+ if (session.idleTimer !== null) {
529
+ clearTimeout(session.idleTimer);
530
+ session.idleTimer = null;
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Tell the session's listener that a screen read landed.
536
+ *
537
+ * The listener is somebody else's code, so its throw is caught here rather
538
+ * than left to {@link WatchSessionManager.observeNow}'s own catch, which
539
+ * would log it as the observation having failed when the observation is the
540
+ * one thing that provably worked.
541
+ */
542
+ private announceObservation(session: ActiveWatchSession): void {
543
+ if (session.onObservation === undefined) {
544
+ return;
545
+ }
546
+ try {
547
+ session.onObservation();
548
+ } catch (err) {
549
+ log.warn(
550
+ { err, sessionId: session.sessionId },
551
+ "Watch observation listener threw",
552
+ );
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Count an append that landed. A refused one is logged and the session
558
+ * carries on: the store turns away an entry whose conversation is gone and
559
+ * one whose observation carried nothing, and neither is a reason to stop
560
+ * watching.
561
+ */
562
+ private recordAppend(
563
+ session: ActiveWatchSession,
564
+ result: WatchAppendResult,
565
+ ): void {
566
+ if (result.ok) {
567
+ session.entryCount += 1;
568
+ return;
569
+ }
570
+ log.debug(
571
+ { sessionId: session.sessionId, reason: result.reason },
572
+ "Watch timeline refused an entry",
573
+ );
574
+ }
575
+ }