@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.
- package/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- 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
|
+
}
|