@memberjunction/realtime-runtime 6.2.0-edge.3 → 6.2.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +75 -0
  2. package/dist/channels/base-realtime-channel-client.d.ts +535 -28
  3. package/dist/channels/base-realtime-channel-client.d.ts.map +1 -1
  4. package/dist/channels/base-realtime-channel-client.js +864 -10
  5. package/dist/channels/base-realtime-channel-client.js.map +1 -1
  6. package/dist/channels/channel-action-dispatcher.d.ts +70 -0
  7. package/dist/channels/channel-action-dispatcher.d.ts.map +1 -0
  8. package/dist/channels/channel-action-dispatcher.js +136 -0
  9. package/dist/channels/channel-action-dispatcher.js.map +1 -0
  10. package/dist/channels/channel-catalog-note.d.ts +48 -0
  11. package/dist/channels/channel-catalog-note.d.ts.map +1 -0
  12. package/dist/channels/channel-catalog-note.js +119 -0
  13. package/dist/channels/channel-catalog-note.js.map +1 -0
  14. package/dist/channels/channel-contract-types.d.ts +107 -0
  15. package/dist/channels/channel-contract-types.d.ts.map +1 -0
  16. package/dist/channels/channel-contract-types.js +12 -0
  17. package/dist/channels/channel-contract-types.js.map +1 -0
  18. package/dist/channels/channel-descriptor-synthesis.d.ts +56 -0
  19. package/dist/channels/channel-descriptor-synthesis.d.ts.map +1 -0
  20. package/dist/channels/channel-descriptor-synthesis.js +90 -0
  21. package/dist/channels/channel-descriptor-synthesis.js.map +1 -0
  22. package/dist/channels/channel-perception.d.ts +82 -0
  23. package/dist/channels/channel-perception.d.ts.map +1 -0
  24. package/dist/channels/channel-perception.js +120 -0
  25. package/dist/channels/channel-perception.js.map +1 -0
  26. package/dist/channels/channel-schema-format.d.ts +17 -0
  27. package/dist/channels/channel-schema-format.d.ts.map +1 -0
  28. package/dist/channels/channel-schema-format.js +45 -0
  29. package/dist/channels/channel-schema-format.js.map +1 -0
  30. package/dist/channels/channel-state-delta.d.ts +59 -0
  31. package/dist/channels/channel-state-delta.d.ts.map +1 -0
  32. package/dist/channels/channel-state-delta.js +116 -0
  33. package/dist/channels/channel-state-delta.js.map +1 -0
  34. package/dist/channels/channel-surface-placement.d.ts +25 -0
  35. package/dist/channels/channel-surface-placement.d.ts.map +1 -0
  36. package/dist/channels/channel-surface-placement.js +29 -0
  37. package/dist/channels/channel-surface-placement.js.map +1 -0
  38. package/dist/channels/channel-visual-pump.d.ts +126 -0
  39. package/dist/channels/channel-visual-pump.d.ts.map +1 -0
  40. package/dist/channels/channel-visual-pump.js +174 -0
  41. package/dist/channels/channel-visual-pump.js.map +1 -0
  42. package/dist/hosts/IRealtimeMediaHost.d.ts +61 -4
  43. package/dist/hosts/IRealtimeMediaHost.d.ts.map +1 -1
  44. package/dist/hosts/IRealtimeMediaHost.js +2 -1
  45. package/dist/hosts/IRealtimeMediaHost.js.map +1 -1
  46. package/dist/index.d.ts +21 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +24 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/session/RealtimeSessionRuntime.d.ts +727 -74
  51. package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
  52. package/dist/session/RealtimeSessionRuntime.js +1732 -242
  53. package/dist/session/RealtimeSessionRuntime.js.map +1 -1
  54. package/dist/session/RecordingShardQueue.d.ts +77 -0
  55. package/dist/session/RecordingShardQueue.d.ts.map +1 -0
  56. package/dist/session/RecordingShardQueue.js +172 -0
  57. package/dist/session/RecordingShardQueue.js.map +1 -0
  58. package/dist/session/app-client-tool-registry.d.ts +76 -0
  59. package/dist/session/app-client-tool-registry.d.ts.map +1 -0
  60. package/dist/session/app-client-tool-registry.js +100 -0
  61. package/dist/session/app-client-tool-registry.js.map +1 -0
  62. package/dist/session/avatar-notice.d.ts +43 -0
  63. package/dist/session/avatar-notice.d.ts.map +1 -0
  64. package/dist/session/avatar-notice.js +60 -0
  65. package/dist/session/avatar-notice.js.map +1 -0
  66. package/dist/session/call-end.d.ts +21 -0
  67. package/dist/session/call-end.d.ts.map +1 -0
  68. package/dist/session/call-end.js +19 -0
  69. package/dist/session/call-end.js.map +1 -0
  70. package/dist/session/channel-exposure-preferences.d.ts +70 -0
  71. package/dist/session/channel-exposure-preferences.d.ts.map +1 -0
  72. package/dist/session/channel-exposure-preferences.js +138 -0
  73. package/dist/session/channel-exposure-preferences.js.map +1 -0
  74. package/dist/session/channel-session-scope.d.ts +142 -0
  75. package/dist/session/channel-session-scope.d.ts.map +1 -0
  76. package/dist/session/channel-session-scope.js +128 -0
  77. package/dist/session/channel-session-scope.js.map +1 -0
  78. package/dist/session/client-session-deadline.d.ts +28 -0
  79. package/dist/session/client-session-deadline.d.ts.map +1 -0
  80. package/dist/session/client-session-deadline.js +50 -0
  81. package/dist/session/client-session-deadline.js.map +1 -0
  82. package/dist/session/realtime-captures.d.ts +236 -0
  83. package/dist/session/realtime-captures.d.ts.map +1 -0
  84. package/dist/session/realtime-captures.js +383 -0
  85. package/dist/session/realtime-captures.js.map +1 -0
  86. package/dist/session/realtime-microphone.d.ts +50 -0
  87. package/dist/session/realtime-microphone.d.ts.map +1 -0
  88. package/dist/session/realtime-microphone.js +51 -0
  89. package/dist/session/realtime-microphone.js.map +1 -0
  90. package/dist/session/session-event-hub.d.ts +72 -0
  91. package/dist/session/session-event-hub.d.ts.map +1 -0
  92. package/dist/session/session-event-hub.js +151 -0
  93. package/dist/session/session-event-hub.js.map +1 -0
  94. package/dist/session/session-launcher.d.ts +128 -0
  95. package/dist/session/session-launcher.d.ts.map +1 -0
  96. package/dist/session/session-launcher.js +160 -0
  97. package/dist/session/session-launcher.js.map +1 -0
  98. package/package.json +9 -9
@@ -1,3 +1,13 @@
1
+ import { Subject } from 'rxjs';
2
+ import { IsPlainObject } from '@memberjunction/global';
3
+ import { CHANNEL_INBOUND_VIDEO_TRACK } from '@memberjunction/ai';
4
+ import { ChannelInboundVideoBridge } from '@memberjunction/ai-realtime-client';
5
+ import { CompareExposure, DescribeExposureLimit, DescribeWithheldVerb, IsVerbWithheld, MinExposure, WithheldVerbs, ValidateJsonAgainstSchemaSubset, } from '@memberjunction/ai-core-plus';
6
+ import { SynthesizeChannelDescriptor } from './channel-descriptor-synthesis.js';
7
+ import { ChannelPerceptionCoalescer, DEFAULT_CHANNEL_PERCEPTION_OPTIONS } from './channel-perception.js';
8
+ import { FormatChannelNote } from './channel-state-delta.js';
9
+ import { VisualPerceptionPump } from './channel-visual-pump.js';
10
+ import { DEFAULT_CHANNEL_SURFACE_PLACEMENT } from './channel-surface-placement.js';
1
11
  /**
2
12
  * Base class for CLIENT-SIDE interactive-channel plugins (per
3
13
  * `plans/ai-agent-sessions.md` → "Interactive Channels" / "Pluggable Channel Interfaces").
@@ -32,10 +42,13 @@
32
42
  *
33
43
  * ### Lifecycle — ONE INSTANCE PER SESSION (not a singleton)
34
44
  * `ClassFactory.CreateInstance` → {@link Initialize}(ctx) → zero or more
35
- * {@link BindSurface}/{@link UnbindSurface} cycles (the surface pane is created/destroyed
36
- * with the overlay's tab panel, e.g. collapse/expand) → {@link Dispose} at teardown.
45
+ * {@link BindSurface}/{@link UnbindSurface} cycles (the Angular overlay creates the surface the
46
+ * first time it is shown and keeps it until the channel leaves the session; another host may
47
+ * recreate it), each telling the plugin when its surface goes in and out of sight
48
+ * ({@link OnSurfaceVisibilityChange}) and where it is placed ({@link OnSurfacePlacementChange})
49
+ * → {@link Dispose} at teardown.
37
50
  * {@link ApplyAgentTool} MUST work with NO surface bound (apply to the state engine
38
- * directly; skip the UI garnish) — tool calls can arrive while the panel is collapsed.
51
+ * directly; skip the UI garnish) — tool calls can arrive before the surface is first shown.
39
52
  *
40
53
  * @typeParam TSurface The plugin's Angular surface component type. The host only ever
41
54
  * sees the default (`object`) — the typed parameter exists so concrete plugins get a
@@ -48,6 +61,7 @@ export class BaseRealtimeChannelClient {
48
61
  * `null` outside that window — guard with `?.` in any code that can run early/late.
49
62
  */
50
63
  this.Context = null;
64
+ this.surfacePlacement = DEFAULT_CHANNEL_SURFACE_PLACEMENT;
51
65
  /**
52
66
  * Max time {@link ResolveAgentSessionId} waits for the session id to bind before giving up, and the
53
67
  * poll interval it re-checks on. Protected so tests can shrink the wait; production keeps the
@@ -55,6 +69,62 @@ export class BaseRealtimeChannelClient {
55
69
  */
56
70
  this.SessionIdWaitTimeoutMs = 8000;
57
71
  this.SessionIdWaitIntervalMs = 200;
72
+ this.channelEventsSubject = new Subject();
73
+ this.channelOutputSubject = new Subject();
74
+ /**
75
+ * Everything this channel streams out, as typed events: state changes, discrete user actions, frames
76
+ * pushed, opened/completed. Hosts subscribe (the embeddable element re-emits these as DOM events);
77
+ * the model is told about a change through a coalesced note instead — see {@link RecordChange}.
78
+ * Completes at {@link Dispose}.
79
+ */
80
+ this.Events$ = this.channelEventsSubject.asObservable();
81
+ /** What the channel handed back when it completed (see {@link Complete}). Completes at {@link Dispose}. */
82
+ this.Output$ = this.channelOutputSubject.asObservable();
83
+ // ── Contract v2: change tracking + structured perception ───────────────────
84
+ /** Monotonic id of the latest state change; tags state events and the frames that describe them. */
85
+ this.channelChangeSeq = 0;
86
+ /** Re-entrancy guard for the default {@link ApplyVerb} ↔ {@link ApplyAgentTool} bridge. */
87
+ this.bridgingVerbAndNativeTool = false;
88
+ this.channelPerception = null;
89
+ /** Dev-check messages already logged, so a drifting state warns once, not on every note. */
90
+ this.warnedStateIssueKeys = new Set();
91
+ /** Tuning for the perception coalescer (debounce window, max note size). Override to change it. */
92
+ this.PerceptionOptions = DEFAULT_CHANNEL_PERCEPTION_OPTIONS;
93
+ /** Why exposure is below the channel's ceiling, as sentences the agent can be told. */
94
+ this.exposureReasons = [];
95
+ // ── Contract v2: visual perception (the change-driven frame pump) ──────────
96
+ /** The frame source the visual pump captures from, once {@link EnableVisualPerception} ran. */
97
+ this.visualFrameProvider = null;
98
+ this.visualPerceptionOptions = {};
99
+ this.visualFramePump = null;
100
+ /**
101
+ * The shared frame bridge that carries frames to the model's inbound video track. Deliberately NOT named
102
+ * `videoBridge`: channels that predate the pump (the Remote Browser, app channels) declare their own
103
+ * private `videoBridge`, and a second private of the same name in the base class would stop them compiling.
104
+ */
105
+ this.VisualVideoBridge = null;
106
+ /** Stops refreshing the model's picture when the bridge's source is sent again; set while the bridge exists. */
107
+ this.stopRefreshOnSourceActivated = null;
108
+ }
109
+ /**
110
+ * The shared name prefix of every tool this channel exposes (e.g. `'Whiteboard_'`).
111
+ * The host registers ONE local-execution route per plugin: tool calls whose name starts
112
+ * with this prefix go to {@link ApplyAgentTool} instead of the server relay.
113
+ *
114
+ * Default `''` — a v2 channel with NO native tools (it is reached only through the `ContextTool`
115
+ * proxy). The host registers no route for an empty prefix: an empty prefix would match *every*
116
+ * tool name and hijack the server relay.
117
+ */
118
+ get ToolNamePrefix() {
119
+ return '';
120
+ }
121
+ /** Label for the channel's tab on the overlay's surface panel (e.g. `'Whiteboard'`). Defaults to {@link ChannelName}. */
122
+ get TabTitle() {
123
+ return this.ChannelName;
124
+ }
125
+ /** Font Awesome icon class for the channel's tab (e.g. `'fa-solid fa-chalkboard'`). Defaults to a generic puzzle-piece. */
126
+ get TabIcon() {
127
+ return 'fa-solid fa-puzzle-piece';
58
128
  }
59
129
  /**
60
130
  * OPTIONAL accent color for the channel's tab (a CSS color string, e.g. an `hsl()` /
@@ -66,6 +136,45 @@ export class BaseRealtimeChannelClient {
66
136
  get TabColor() {
67
137
  return null;
68
138
  }
139
+ /**
140
+ * The channel's CLIENT-EXECUTED tool declarations, aggregated by the session service
141
+ * into the `clientTools` set declared to the realtime model at session mint. The server
142
+ * only DECLARES these — execution stays in the browser via {@link ApplyAgentTool}.
143
+ *
144
+ * Default `[]` — a v2 channel whose verbs are reached through the proxy only.
145
+ */
146
+ GetToolDefinitions() {
147
+ return [];
148
+ }
149
+ /**
150
+ * Executes ONE agent tool call locally (the ACTION direction) and returns the result
151
+ * JSON string fed back to the model as the `tool_response`. Called for every tool whose
152
+ * name starts with {@link ToolNamePrefix}. Must work both WITH a bound surface (apply +
153
+ * UI garnish) and WITHOUT one (apply to the state engine directly — the surface may not
154
+ * exist yet, e.g. it has not been shown). Should not throw: return a
155
+ * `{ success: false, error }` payload so the model can narrate the failure (the host
156
+ * additionally wraps anything thrown).
157
+ *
158
+ * **This is the LEGACY entry point.** Channels written against the v2 contract override
159
+ * {@link ApplyVerb} instead and inherit this method: the default maps the tool name back to its verb,
160
+ * calls {@link ApplyVerb} as the agent, and serializes the structured result. Channels written
161
+ * against v1 override THIS and inherit {@link ApplyVerb}, which adapts it. A channel must override
162
+ * at least one of the two — overriding neither is reported as a failure result, never a recursion.
163
+ */
164
+ ApplyAgentTool(toolName, argsJson) {
165
+ if (this.bridgingVerbAndNativeTool) {
166
+ return JSON.stringify({ success: false, error: this.implementsNeitherMessage() });
167
+ }
168
+ this.bridgingVerbAndNativeTool = true;
169
+ let result;
170
+ try {
171
+ result = this.ApplyVerb(this.ResolveVerbForTool(toolName), parseToolArguments(argsJson), 'agent');
172
+ }
173
+ finally {
174
+ this.bridgingVerbAndNativeTool = false;
175
+ }
176
+ return result instanceof Promise ? result.then(serializeVerbResult) : serializeVerbResult(result);
177
+ }
69
178
  /**
70
179
  * The Angular component the overlay creates dynamically as this channel's tab pane, or `null`
71
180
  * for a **server-only** channel that renders no MJ surface (its surface, if any, lives on the
@@ -107,14 +216,77 @@ export class BaseRealtimeChannelClient {
107
216
  return null;
108
217
  }
109
218
  /**
110
- * Called by the host when the surface component is being destroyed (tab panel
111
- * collapsed / overlay torn down). Drop the instance reference and unsubscribe any
219
+ * Called by the host right after it created the surface component (and BEFORE the
220
+ * component's first change detection, so inputs set here are visible in its `ngOnInit`).
221
+ * The plugin — which knows its own component type — sets inputs (state engine, agent
222
+ * name, …) and subscribes outputs here, wiring perception/garnish flows back through
223
+ * {@link Context}. May be called again with a NEW instance after an
224
+ * {@link UnbindSurface} (the host recreated the surface).
225
+ */
226
+ BindSurface(_instance) {
227
+ // default: a channel with no surface has nothing to bind
228
+ }
229
+ /**
230
+ * Called by the host when the surface component is being destroyed (the channel left
231
+ * the session / the overlay was torn down). Drop the instance reference and unsubscribe any
112
232
  * output subscriptions — after this, {@link ApplyAgentTool} runs in its no-surface
113
233
  * mode. Default: no-op.
114
234
  */
115
235
  UnbindSurface() {
116
236
  // default: nothing to release
117
237
  }
238
+ /**
239
+ * Called by the host when the bound surface comes into sight or goes out of it (the panel collapsed or hidden,
240
+ * another tab active, the call minimized). The surface stays bound and keeps its state; a channel can pause work
241
+ * nobody sees, such as a screenshot poll, and resume it when the surface is shown again. The host calls it right
242
+ * after {@link BindSurface} with the surface's current visibility, then on every change, and not after
243
+ * {@link UnbindSurface}. Default: no-op, so a surface keeps running out of sight.
244
+ */
245
+ OnSurfaceVisibilityChange(_visible) {
246
+ // default: nothing to pause
247
+ }
248
+ /**
249
+ * Called by the host when the bound surface moves: to the stage (it fills the call), to its tab, or out of sight
250
+ * because the user hid it. A channel can adapt its own chrome, such as a "Move to stage" button that has nothing to
251
+ * do once the surface is on the stage. Called right after {@link BindSurface} with the current placement, then on
252
+ * every move, and not after {@link UnbindSurface}. Default: no-op.
253
+ */
254
+ OnSurfacePlacementChange(_placement) {
255
+ // default: a surface looks the same wherever it is placed
256
+ }
257
+ /**
258
+ * Where this channel's surface shows when a call starts, and where the user may move it: from the channel's registry
259
+ * row (`UIConfig.Placement` and `UIConfig.AllowedPlacements`). A channel without a row, or whose row says nothing,
260
+ * starts on its tab and can go anywhere.
261
+ */
262
+ get SurfacePlacement() {
263
+ return this.surfacePlacement;
264
+ }
265
+ /**
266
+ * Sets {@link SurfacePlacement}. The runtime calls it with the registry row's placement when it builds the channel, before
267
+ * the channel mounts, as it applies exposure policy with {@link ApplyExposure}.
268
+ */
269
+ ApplySurfacePlacement(placement) {
270
+ this.surfacePlacement = placement;
271
+ }
272
+ /**
273
+ * Whether the user may share this channel's surface on its own: a host that offers it lists the surface, named by
274
+ * {@link TabTitle} with {@link TabIcon}, under "This panel" in its Share menu while the surface is on screen, and the
275
+ * share then shows only that surface. Default `false`, so a channel opts in with this getter. A surface that shows the
276
+ * agent its own call (the camera, the screen share, the agent's video) never should.
277
+ */
278
+ get SurfaceShareable() {
279
+ return false;
280
+ }
281
+ /**
282
+ * The runtime capture this channel fronts (`'camera'` or `'screen'`), or `null`. Such a channel is the capture's policy:
283
+ * the runtime starts the capture only while the channel is in the session and the session's policy lets the agent see
284
+ * pixels through it, and the user's "agent can see" choice for the channel decides whether the capture's frames reach
285
+ * the model. Default `null`.
286
+ */
287
+ get CaptureKind() {
288
+ return null;
289
+ }
118
290
  /**
119
291
  * Binds the host context and invokes the {@link OnInitialize} hook. Called exactly once
120
292
  * per session, right after ClassFactory instantiation and before any tool call or
@@ -134,12 +306,37 @@ export class BaseRealtimeChannelClient {
134
306
  }
135
307
  /**
136
308
  * Subclass hook invoked once the realtime session is connected and live (the client driver
137
- * is created, connected, and media tracks negotiated). Channels that establish media bridges
138
- * (e.g. video streaming) can start them here when `Context.Client` is available.
139
- * Default: no-op.
309
+ * is created, connected, and media tracks negotiated), or when the channel is opened in a live
310
+ * session. Channels that establish media bridges (e.g. video streaming) can start them here when
311
+ * `Context.Client` is available.
312
+ * Default: a channel with visual perception adds the inbound video track it sources when the call
313
+ * has none (it was opened mid-call) and brings its frame bridge up; any other channel does nothing.
314
+ * An override that keeps visual perception calls `super.OnSessionStarted()`.
140
315
  */
141
316
  OnSessionStarted() {
142
- // default: nothing to do
317
+ // A channel with visual perception brings its frame bridge up now: the video track is only
318
+ // negotiated once the session connects, so this is the first moment it can be established.
319
+ if (this.visualFrameProvider) {
320
+ this.addVisualTrackMidCall();
321
+ this.EnsureVideoBridge();
322
+ }
323
+ }
324
+ /**
325
+ * Adds the inbound video track this channel sources ({@link GetSourcedTracks}) when the call is live without one. A
326
+ * channel open when the call connected had its track negotiated then, so this does nothing for it. A channel opened
327
+ * mid-call (`on-demand`) was not part of that negotiation: without its own track it could only send on a track a camera
328
+ * or screen share added, which goes when that capture stops. Only on a model that takes inbound video.
329
+ */
330
+ addVisualTrackMidCall() {
331
+ const client = this.Context?.Client;
332
+ if (!client?.SupportsInboundVideo || client.IsTrackEstablished('video', 'inbound')) {
333
+ return;
334
+ }
335
+ for (const track of this.GetSourcedTracks()) {
336
+ if (track.Direction === 'inbound' && String(track.Modality).trim().toLowerCase() === 'video') {
337
+ client.AddTrack(track);
338
+ }
339
+ }
143
340
  }
144
341
  /**
145
342
  * Resolves the live {@link RealtimeChannelContext.AgentSessionID}, briefly WAITING for it when it
@@ -210,15 +407,600 @@ export class BaseRealtimeChannelClient {
210
407
  * Default `[]`.
211
408
  */
212
409
  GetSourcedTracks() {
213
- return [];
410
+ // A channel that enabled visual perception sources the inbound video track the pump writes to — unless the
411
+ // agent's policy has already ruled pixels out (the user's own toggle does not: a user who turns the agent's
412
+ // view back on mid-call needs the track to exist). Not requesting video is also what keeps a session out of
413
+ // the shorter session limits video carries.
414
+ return this.visualFrameProvider && this.policyAllowsPixels() ? [CHANNEL_INBOUND_VIDEO_TRACK] : [];
214
415
  }
215
416
  /**
216
417
  * Media tracks this client channel can SINK — samples flowing from the model OUT.
217
418
  * Default `[]`.
419
+ *
420
+ * The session requests them when it connects, so the driver establishes the ones the model supports. A channel that
421
+ * sinks outbound video shows the agent's video: it follows {@link RealtimeChannelContext.AgentVideo$}, and counts as
422
+ * used once the video arrives, so the host shows its surface.
218
423
  */
219
424
  GetSunkTracks() {
220
425
  return [];
221
426
  }
427
+ /**
428
+ * Whether this channel shows the agent's video: it sinks outbound video ({@link GetSunkTracks}). The runtime marks it
429
+ * as used when the video arrives, and a host presents its surface as the agent (the call overlay puts it in the agent's
430
+ * place in the call rather than in the focus layout).
431
+ */
432
+ get ShowsAgentVideo() {
433
+ return this.GetSunkTracks().some((t) => t.Direction === 'outbound' && String(t.Modality).trim().toLowerCase() === 'video');
434
+ }
435
+ // ── Contract v2: descriptor, state, verbs, events, open/complete ───────────
436
+ /**
437
+ * The channel's stable primary-instance id. A single-instance channel has exactly one; a
438
+ * {@link RealtimeChannelDescriptor.MultiInstance} channel owns further instance ids itself and
439
+ * routes by the `instanceId` argument of {@link ApplyVerb}. Appears in every structured note
440
+ * (`[channel:<Key>#<instance>] …`) and every {@link RealtimeChannelEvent}.
441
+ */
442
+ get InstanceId() {
443
+ return PRIMARY_CHANNEL_INSTANCE_ID;
444
+ }
445
+ /**
446
+ * The channel's SELF-DESCRIPTION — what it is, what it holds, what it accepts, how it behaves in a
447
+ * session. This is the contract an agent reads to operate a channel it has never seen, and what the
448
+ * runtime reads to scope, advertise and validate. Pure data; called often, so keep it cheap.
449
+ *
450
+ * Default: a descriptor SYNTHESIZED from the v1 members (tools → verbs, intro → instructions),
451
+ * stamped with the legacy contract version — so a channel that implements nothing new is still
452
+ * describable and works exactly as before. A v2 channel overrides this with an authored descriptor.
453
+ * (Build verbs from {@link GetToolDefinitions} with `BuildToolBackedVerbs` when they are the
454
+ * native tools, so a subclass that adds a tool does not leave the descriptor lying.)
455
+ */
456
+ GetDescriptor() {
457
+ return SynthesizeChannelDescriptor(this);
458
+ }
459
+ /**
460
+ * A snapshot of the channel's things (the descriptor's nouns), keyed by noun name. Perception notes
461
+ * are computed as deltas between successive snapshots, so this must be a pure read.
462
+ *
463
+ * Default: the parsed {@link SerializeState} when that is a JSON object, else `{}` — which makes a
464
+ * v1 channel's state of record its (single, opaque) snapshot with no code change.
465
+ */
466
+ GetState() {
467
+ const serialized = this.SerializeState();
468
+ if (serialized === null) {
469
+ return {};
470
+ }
471
+ try {
472
+ const parsed = JSON.parse(serialized);
473
+ // JSON.parse output is JSON by construction, so narrowing it to JSONObject is not a type lie.
474
+ return IsPlainObject(parsed) ? parsed : { state: parsed };
475
+ }
476
+ catch (error) {
477
+ // A channel whose state of record is not JSON has no structured snapshot to offer; say so once
478
+ // (this runs on every perception note) rather than silently presenting it as empty.
479
+ this.warnStateIssueOnce('unparseable-serialized-state', `SerializeState() did not return JSON, so GetState() reports an empty snapshot: ${error instanceof Error ? error.message : String(error)}`);
480
+ return {};
481
+ }
482
+ }
483
+ /**
484
+ * Checks the current {@link GetState} snapshot against the descriptor's noun schemas and returns
485
+ * every violation (empty when valid). Useful in a channel's own tests; the runtime also runs it on
486
+ * every perception note when {@link StrictStateChecks} is on.
487
+ */
488
+ ValidateState() {
489
+ const state = this.GetState();
490
+ const issues = [];
491
+ for (const noun of this.GetDescriptor().Nouns) {
492
+ if (noun.Name in state) {
493
+ issues.push(...ValidateJsonAgainstSchemaSubset(state[noun.Name], noun.Schema, `$.${noun.Name}`));
494
+ }
495
+ }
496
+ return issues;
497
+ }
498
+ /**
499
+ * Whether state snapshots are validated against the descriptor's noun schemas as notes are built —
500
+ * a development aid that surfaces a channel whose state drifted from its own declaration. On by
501
+ * default only when a Node-style environment reports a non-production `NODE_ENV` (tests, tooling);
502
+ * a browser production build never pays for it. Hosts may set it explicitly.
503
+ */
504
+ static { this.StrictStateChecks = isDevelopmentEnvironment(); }
505
+ /**
506
+ * Runs one verb of this channel — the contract-v2 entry point, reached through the `ContextTool`
507
+ * proxy (and, via {@link ApplyAgentTool}, by native tool calls).
508
+ *
509
+ * The runtime has ALREADY resolved the verb against the descriptor, enforced `InvokableBy`, and
510
+ * validated `args` against the verb's parameter schema before calling this, so an override can
511
+ * trust its inputs' shape. It must work with no surface bound, and should not throw — return a
512
+ * failure result so the model can recover.
513
+ *
514
+ * Default: adapts the legacy {@link ApplyAgentTool} (the verb's native tool name, args as JSON),
515
+ * so every v1 channel is reachable through the proxy with no change. A v2 channel overrides this.
516
+ *
517
+ * @param verb The verb name (as declared in the descriptor).
518
+ * @param args The validated parameters.
519
+ * @param actor Who is acting: the agent (a model call) or the user (their own interaction).
520
+ * @param instanceId Which instance of a multi-instance channel; `undefined` for the primary instance.
521
+ */
522
+ ApplyVerb(verb, args, actor, instanceId) {
523
+ if (this.bridgingVerbAndNativeTool) {
524
+ return { Success: false, ErrorCode: 'verb_failed', Error: this.implementsNeitherMessage() };
525
+ }
526
+ this.bridgingVerbAndNativeTool = true;
527
+ let outcome;
528
+ try {
529
+ outcome = this.ApplyAgentTool(this.ResolveNativeToolName(verb), JSON.stringify(args));
530
+ }
531
+ finally {
532
+ this.bridgingVerbAndNativeTool = false;
533
+ }
534
+ return outcome instanceof Promise ? outcome.then(parseLegacyVerbResult) : parseLegacyVerbResult(outcome);
535
+ }
536
+ /**
537
+ * Maps a native tool name back to its verb: the descriptor's `NativeToolName` match first, then the
538
+ * name minus {@link ToolNamePrefix}. Protected so a channel with an unusual naming scheme can
539
+ * override it.
540
+ */
541
+ ResolveVerbForTool(toolName) {
542
+ const declared = this.GetDescriptor().Verbs.find((v) => v.NativeToolName === toolName);
543
+ if (declared) {
544
+ return declared.Name;
545
+ }
546
+ const prefix = this.ToolNamePrefix;
547
+ return prefix.length > 0 && toolName.length > prefix.length && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
548
+ }
549
+ /** Maps a verb to the native tool name it is reachable as (inverse of {@link ResolveVerbForTool}). */
550
+ ResolveNativeToolName(verb) {
551
+ const wanted = verb.trim().toLowerCase();
552
+ const declared = this.GetDescriptor().Verbs.find((v) => v.Name.toLowerCase() === wanted);
553
+ if (declared?.NativeToolName) {
554
+ return declared.NativeToolName;
555
+ }
556
+ const prefix = this.ToolNamePrefix;
557
+ return prefix.length > 0 && !verb.startsWith(prefix) ? `${prefix}${verb}` : verb;
558
+ }
559
+ /**
560
+ * The descriptor verb a native tool call runs, or `undefined` when the tool is not one of the channel's verbs
561
+ * (the runtime uses it to apply {@link RefuseVerbForExposure} on the native-tool route as well as the `ContextTool` one).
562
+ *
563
+ * @param toolName The native tool name the model called.
564
+ */
565
+ FindVerbForNativeTool(toolName) {
566
+ const wanted = this.ResolveVerbForTool(toolName).toLowerCase();
567
+ return this.GetDescriptor().Verbs.find((v) => v.Name.toLowerCase() === wanted);
568
+ }
569
+ /**
570
+ * Whether the agent may be given this verb's result at the channel's current exposure. A verb that declares
571
+ * `ReturnsChannelData` is refused whole when exposure is below that level, because its result would hand the agent
572
+ * what the user or a zero-data-retention policy held back from it. Policy, not redaction: the result is never filtered.
573
+ *
574
+ * Applies to the AGENT (the dispatcher and the native-tool route call it); a user acting through the surface is not restricted.
575
+ *
576
+ * @param verb The verb the agent is calling.
577
+ * @returns The sentence to give the agent, or `null` when the call may proceed.
578
+ */
579
+ RefuseVerbForExposure(verb) {
580
+ const effective = this.Exposure;
581
+ return IsVerbWithheld(verb, effective) ? DescribeWithheldVerb(verb.Name, this.GetDescriptor().DisplayName, effective, this.exposureReasons) : null;
582
+ }
583
+ /** Why a channel that overrides neither entry point cannot run anything. */
584
+ implementsNeitherMessage() {
585
+ return `Channel '${this.ChannelName}' implements neither ApplyVerb nor ApplyAgentTool.`;
586
+ }
587
+ /**
588
+ * Opens the channel — mounts it for use and seeds it with `inputs`. Called by the runtime when the
589
+ * agent (or user) opens an `on-demand` channel, after the channel has been initialized.
590
+ *
591
+ * Validates `inputs` against the descriptor's `Inputs` schema (a violation is a structured
592
+ * `'invalid_params'` failure, never a half-opened channel), runs the {@link OnOpen} hook, then tells
593
+ * the model the channel is open and what it currently holds (`opened` note with a state snapshot) —
594
+ * and takes that snapshot as the baseline later changes are described against.
595
+ *
596
+ * @param inputs What the agent asked to seed the channel with.
597
+ */
598
+ async Open(inputs = {}) {
599
+ const descriptor = this.GetDescriptor();
600
+ if (descriptor.Inputs) {
601
+ const issues = ValidateJsonAgainstSchemaSubset(inputs, descriptor.Inputs);
602
+ if (issues.length > 0) {
603
+ return {
604
+ Success: false,
605
+ ErrorCode: 'invalid_params',
606
+ Error: `The inputs for opening ${descriptor.Key} are invalid.`,
607
+ Details: issues
608
+ };
609
+ }
610
+ }
611
+ const opened = await this.OnOpen(inputs);
612
+ if (opened && !opened.Success) {
613
+ return opened;
614
+ }
615
+ // A multi-instance channel names the instance it just created; every other channel has one id.
616
+ const instanceId = opened?.Instance ?? this.InstanceId;
617
+ this.EmitChannelEvent('opened', { inputs }, undefined, instanceId);
618
+ if (this.Exposure === 'none') {
619
+ // The agent opened it and may use it, but exposure policy keeps what it holds from the model.
620
+ this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'opened', { exposure: 'none' }));
621
+ }
622
+ else {
623
+ const state = this.GetState();
624
+ this.ensurePerception().SetBaseline(state);
625
+ this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'opened', { state }));
626
+ }
627
+ const extra = opened && IsPlainObject(opened.Result) ? opened.Result : {};
628
+ return { Success: true, Result: { opened: true, channel: descriptor.Key, instance: instanceId, ...extra } };
629
+ }
630
+ /**
631
+ * Subclass hook for {@link Open}: apply the (already validated) seed `inputs`. Return a failure
632
+ * result to refuse the open; return nothing for success. Default: nothing to do.
633
+ */
634
+ OnOpen(_inputs) {
635
+ // default: a channel with no seed data has nothing to apply
636
+ }
637
+ /**
638
+ * The channel finished and is handing something back (a submitted form, a chosen option, a
639
+ * finished game). Emits on {@link Output$} and {@link Events$} and tells the model with a
640
+ * `completed` note. The output is validated against the descriptor's `Output` schema when
641
+ * {@link StrictStateChecks} is on; a mismatch is logged, never swallowed into silence or thrown.
642
+ *
643
+ * @param output What the channel hands back.
644
+ * @param instanceId The instance that completed, for a multi-instance channel (default: the primary instance).
645
+ */
646
+ Complete(output, instanceId = this.InstanceId) {
647
+ const descriptor = this.GetDescriptor();
648
+ if (descriptor.Output && BaseRealtimeChannelClient.StrictStateChecks) {
649
+ const issues = ValidateJsonAgainstSchemaSubset(output, descriptor.Output, '$');
650
+ if (issues.length > 0) {
651
+ console.warn(`[RealtimeChannel:${descriptor.Key}] Complete() output does not match the descriptor's Output schema: ${issues.join('; ')}`);
652
+ }
653
+ }
654
+ this.channelOutputSubject.next({ Channel: descriptor.Key, Instance: instanceId, Output: output, OccurredAt: Date.now() });
655
+ this.EmitChannelEvent('completed', output, undefined, instanceId);
656
+ // The output is what the channel holds (a submitted form, a chosen option): state exposure decides
657
+ // whether the model is told it, or only that the channel finished.
658
+ this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'completed', this.Exposure === 'none' ? { exposure: 'none' } : output));
659
+ }
660
+ /** The id of the latest state change (0 before any). */
661
+ get CurrentChangeId() {
662
+ return this.channelChangeSeq;
663
+ }
664
+ /**
665
+ * Records that the channel's state changed. Call this on EVERY mutation, whoever caused it.
666
+ *
667
+ * It (1) assigns the change a monotonic id, (2) emits a `state_changed` event on {@link Events$}
668
+ * immediately (observers see every change), and (3) — unless `Perceive` is `false` — schedules a
669
+ * COALESCED structured note to the model: a burst of N changes inside the debounce window produces
670
+ * ONE `[channel:<Key>#<instance>] state_changed {"changeId":…,"changes":N,"delta":{…}}` note carrying
671
+ * what differs from what the model was last told (the first note is a full snapshot). Notes that
672
+ * would be too large degrade to a list of changed paths.
673
+ *
674
+ * @param options `Author` — who made the change (carried on the event); `Perceive` — whether the
675
+ * model should be told (default `true`; a channel that already feeds the model its own perception
676
+ * — like the Whiteboard's scene deltas — passes `false`).
677
+ * @returns The change's id.
678
+ */
679
+ RecordChange(options = {}) {
680
+ const id = ++this.channelChangeSeq;
681
+ this.EmitChannelEvent('state_changed', options.Author ? { author: options.Author } : {}, id);
682
+ // Observers of Events$ see every change; the MODEL is told only what exposure policy lets it perceive.
683
+ if (options.Perceive !== false && this.Exposure !== 'none') {
684
+ this.ensurePerception().Record(id);
685
+ }
686
+ return id;
687
+ }
688
+ /**
689
+ * Emits a typed event on {@link Events$} (NOT to the model — use {@link RecordChange} for state the
690
+ * model should perceive, or send an explicit note for a discrete event it must hear about).
691
+ *
692
+ * @param name The event name (declare it in the descriptor's `Events`).
693
+ * @param payload The event payload.
694
+ * @param changeId The change id the event relates to, when it relates to one.
695
+ * @param instanceId The instance the event is about, for a multi-instance channel (default: the primary instance).
696
+ */
697
+ EmitChannelEvent(name, payload = {}, changeId, instanceId = this.InstanceId) {
698
+ const event = {
699
+ Channel: this.ChannelName,
700
+ Instance: instanceId,
701
+ Name: name,
702
+ Payload: payload,
703
+ OccurredAt: Date.now()
704
+ };
705
+ if (changeId !== undefined) {
706
+ event.ChangeId = changeId;
707
+ }
708
+ this.channelEventsSubject.next(event);
709
+ }
710
+ /** Sends any pending coalesced perception note now (e.g. before a surface is torn down). */
711
+ FlushPerception() {
712
+ this.channelPerception?.Flush();
713
+ }
714
+ /** The perception coalescer, created on first use. */
715
+ ensurePerception() {
716
+ if (!this.channelPerception) {
717
+ const channel = this;
718
+ this.channelPerception = new ChannelPerceptionCoalescer({
719
+ get ChannelKey() {
720
+ return channel.ChannelName;
721
+ },
722
+ get InstanceId() {
723
+ return channel.InstanceId;
724
+ },
725
+ GetState: () => this.getStateChecked(),
726
+ SendNote: (text) => this.Context?.SendContextNote(text),
727
+ OnError: (error) => console.error(`[RealtimeChannel:${this.ChannelName}] Perception note failed:`, error)
728
+ }, this.PerceptionOptions);
729
+ }
730
+ return this.channelPerception;
731
+ }
732
+ /** {@link GetState}, with the development-time schema check applied when {@link StrictStateChecks} is on. */
733
+ getStateChecked() {
734
+ const state = this.GetState();
735
+ if (BaseRealtimeChannelClient.StrictStateChecks) {
736
+ for (const issue of this.ValidateState()) {
737
+ this.warnStateIssueOnce(issue, `State does not match its descriptor: ${issue}`);
738
+ }
739
+ }
740
+ return state;
741
+ }
742
+ /** Logs a state problem once per channel instance — these checks run on every perception note. */
743
+ warnStateIssueOnce(key, message) {
744
+ if (this.warnedStateIssueKeys.has(key)) {
745
+ return;
746
+ }
747
+ this.warnedStateIssueKeys.add(key);
748
+ console.warn(`[RealtimeChannel:${this.ChannelName}] ${message}`);
749
+ }
750
+ // ── Contract v2: artifacts from delegated runs ─────────────────────────────
751
+ /**
752
+ * Whether this channel wants to be offered the artifacts a delegated run just produced (the agent asked
753
+ * Skip or Sage to build something, and it came back as artifacts). A channel that hosts artifacts says yes
754
+ * for the ones it can show — and for an artifact it ALREADY shows, so a newer version replaces it in place.
755
+ *
756
+ * Asked of every channel in the session, mounted or merely advertised. For an advertised channel it is
757
+ * asked before the channel has been initialized, so it must not depend on {@link Context}; the channel's
758
+ * resolved configuration is passed in for exactly that reason. Saying yes for an advertised channel
759
+ * mounts it, so only answer yes when you will do something with the artifacts.
760
+ *
761
+ * Default: `false` — a channel that does not host artifacts is never bothered.
762
+ *
763
+ * @param artifacts What the delegated run produced.
764
+ * @param config This channel's resolved configuration (host defaults beneath agent/app config).
765
+ */
766
+ AcceptsDelegationArtifacts(_artifacts, _config) {
767
+ return false;
768
+ }
769
+ /**
770
+ * A delegated run produced artifacts this channel said it wants ({@link AcceptsDelegationArtifacts}). The
771
+ * channel is mounted and initialized by now. Never throw into the runtime: handle your own failures and
772
+ * report them (the runtime logs anything that escapes, but cannot recover the work).
773
+ *
774
+ * Default: nothing.
775
+ *
776
+ * @param artifacts What the delegated run produced.
777
+ */
778
+ OnDelegationArtifacts(_artifacts) {
779
+ // default: a channel that does not host artifacts ignores them
780
+ }
781
+ /**
782
+ * How much of this channel the model may perceive without asking: the lowest of the channel's own ceiling
783
+ * (`GetDescriptor().MaxExposure`), the server policy and the user's choice. A channel nobody has applied a
784
+ * policy to simply has its ceiling, which is what every channel had before exposure policy existed.
785
+ *
786
+ * - `'none'`: no state notes and no frames.
787
+ * - `'state'`: structured state notes; no frames.
788
+ * - `'pixels'`: state notes and frames.
789
+ *
790
+ * It governs what flows to the model UNPROMPTED (perception notes, the contents of `opened` / `completed`
791
+ * notes, frames). It does not REDACT what a verb's result contains: a verb is all-or-nothing. A verb whose
792
+ * result would show the model something above this level declares `ReturnsChannelData` on the verb, and
793
+ * when this level is below it the verb is refused whole (`exposure_restricted`) and listed to the model as
794
+ * unavailable; see {@link RefuseVerbForExposure}. A verb that does not declare it returns what the channel
795
+ * says it returns, so a channel whose verbs reveal sensitive data must either declare it or lower its own
796
+ * `MaxExposure`.
797
+ */
798
+ get Exposure() {
799
+ return MinExposure(this.GetDescriptor().MaxExposure, this.policyExposure, this.userExposure);
800
+ }
801
+ /** Why {@link Exposure} is below the channel's ceiling; empty when it is not. */
802
+ get ExposureReasons() {
803
+ return this.exposureReasons;
804
+ }
805
+ /**
806
+ * Whether the server policy (not the user) leaves room for pixels. Deliberately does not consult the
807
+ * descriptor: a synthesized descriptor derives its `MaxExposure` from {@link GetSourcedTracks}, which asks
808
+ * this, so reading it here would recurse. A channel that enabled visual perception has declared pixels.
809
+ */
810
+ policyAllowsPixels() {
811
+ return this.policyExposure === undefined || this.policyExposure === 'pixels';
812
+ }
813
+ /**
814
+ * Applies exposure policy: the server's decision and the user's own choice. The runtime calls this when
815
+ * it mounts the channel and again whenever the user changes their choice.
816
+ *
817
+ * Withdrawing exposure takes effect immediately: a pending perception note is dropped, a pending frame
818
+ * is dropped, and the channel's video source stops being forwarded. Restoring it re-baselines (the
819
+ * model's picture of the channel is stale, so the next note is a full snapshot) and sends a fresh frame.
820
+ * When the call is live the model is told about the change, with the reason, so it never assumes it
821
+ * can still see what it can't.
822
+ *
823
+ * @param settings `Policy` — the server-decided exposure; `User` — the user's choice (omit for none);
824
+ * `Reasons` — why the policy lowered it, from the server's limits.
825
+ */
826
+ ApplyExposure(settings) {
827
+ const previous = this.Exposure;
828
+ this.policyExposure = settings.Policy;
829
+ this.userExposure = settings.User;
830
+ this.exposureReasons = [...(settings.Reasons ?? [])];
831
+ const next = this.Exposure;
832
+ if (previous === next) {
833
+ return;
834
+ }
835
+ this.applyExposureEffects(previous, next);
836
+ this.announceExposureChange(previous, next);
837
+ }
838
+ /** Makes a change in exposure real: stops what is no longer allowed, restarts what is again. */
839
+ applyExposureEffects(previous, next) {
840
+ const hadState = CompareExposure(previous, 'state') >= 0;
841
+ const hasState = CompareExposure(next, 'state') >= 0;
842
+ if (hadState && !hasState) {
843
+ this.channelPerception?.CancelPending();
844
+ }
845
+ else if (!hadState && hasState) {
846
+ // The model's picture of this channel went stale while it could not see it.
847
+ this.channelPerception?.ResetBaseline();
848
+ if (this.Context?.Client) {
849
+ this.ensurePerception().Record(this.channelChangeSeq);
850
+ }
851
+ }
852
+ const hadPixels = previous === 'pixels';
853
+ const hasPixels = next === 'pixels';
854
+ this.VisualVideoBridge?.SetSourceEnabled?.(hasPixels, false);
855
+ if (hadPixels && !hasPixels) {
856
+ this.visualFramePump?.CancelPending();
857
+ }
858
+ else if (!hadPixels && hasPixels) {
859
+ void this.NotifyVisualChange();
860
+ }
861
+ }
862
+ /** Emits the change on {@link Events$} and, when the call is live, tells the model what it can now perceive and why. */
863
+ announceExposureChange(previous, next) {
864
+ const ceiling = this.GetDescriptor().MaxExposure;
865
+ const limit = DescribeExposureLimit(next, ceiling, this.exposureReasons);
866
+ const payload = { exposure: next, was: previous };
867
+ if (limit) {
868
+ payload['limit'] = limit;
869
+ }
870
+ const unavailable = WithheldVerbs(this.GetDescriptor().Verbs, next).map((v) => v.Name);
871
+ if (unavailable.length > 0) {
872
+ payload['unavailableActions'] = unavailable;
873
+ }
874
+ this.EmitChannelEvent('exposure_changed', payload);
875
+ if (this.Context?.Client) {
876
+ this.Context.SendContextNote(FormatChannelNote(this.ChannelName, this.InstanceId, 'exposure_changed', payload));
877
+ }
878
+ }
879
+ /** Default frame cadence when the track negotiated none (1 fps — the Gemini Live ceiling). */
880
+ static { this.DEFAULT_VISUAL_CADENCE_MS = 1000; }
881
+ /** Floor for the negotiated cadence (4 fps) so a fast model never makes a channel spin. */
882
+ static { this.MIN_VISUAL_CADENCE_MS = 250; }
883
+ /**
884
+ * Opts this channel into VISUAL perception: its surface can be shown to a video-capable model as
885
+ * frames. Call it from {@link OnInitialize} with the channel's frame source.
886
+ *
887
+ * The channel then needs only to say *when* its picture changed — {@link NotifyVisualChange} after a
888
+ * user edit, {@link ConfirmVisualChange} after a successful agent edit — and the base class does the
889
+ * rest: change-driven (no idle heartbeat), paced to the negotiated cadence, a trailing settle so the
890
+ * model ends on the final state of a burst, dedupe of identical frames, and a single confirmation
891
+ * frame (with a "do not narrate" note) after an agent edit. Every frame is tagged with the change id
892
+ * of the state it was captured at (`frame_pushed` events), so state and pixels can be cross-checked.
893
+ *
894
+ * Also makes {@link GetSourcedTracks} report the inbound video track, which is what gets it
895
+ * negotiated. Idempotent: calling it again replaces the provider and options.
896
+ *
897
+ * @param provider Renders the channel's current picture as a base64 JPEG (or `null`).
898
+ * @param options See {@link VisualPerceptionOptions}.
899
+ */
900
+ EnableVisualPerception(provider, options = {}) {
901
+ this.visualFrameProvider = provider;
902
+ this.visualPerceptionOptions = options;
903
+ if (this.visualFramePump) {
904
+ this.visualFramePump.CancelPending();
905
+ }
906
+ else {
907
+ this.visualFramePump = this.createVisualPump(provider);
908
+ }
909
+ this.EnsureVideoBridge();
910
+ }
911
+ /**
912
+ * The channel's picture changed because of a USER action (or a scene replacement): push a frame,
913
+ * respecting the cadence. Resolves when the decision has been carried out; never rejects. A no-op
914
+ * until {@link EnableVisualPerception} ran and the session has an inbound video track.
915
+ */
916
+ NotifyVisualChange() {
917
+ return this.visualFramePump ? this.visualFramePump.OnChange() : Promise.resolve();
918
+ }
919
+ /**
920
+ * An AGENT action succeeded and changed the channel's picture: push exactly one confirmation frame
921
+ * now and tell the model not to narrate its own change. Call only on SUCCESS — confirming a failed
922
+ * action would assert an edit landed that did not. A no-op until {@link EnableVisualPerception} ran.
923
+ */
924
+ ConfirmVisualChange() {
925
+ return this.visualFramePump ? this.visualFramePump.Confirm() : Promise.resolve();
926
+ }
927
+ /**
928
+ * Returns the shared frame bridge, creating it if needed and (only when the channel opted into the
929
+ * legacy poller) starting its fixed-rate poll once the video track is up. `null` when no context is bound.
930
+ */
931
+ EnsureVideoBridge() {
932
+ if (!this.VisualVideoBridge && this.Context && this.visualFrameProvider) {
933
+ // The channel registers as a SOURCE with the session's video arbiter (the single writer of inbound
934
+ // video) rather than writing to the model itself, so when several sources are live the arbiter decides
935
+ // which one the model sees and tells it. The id and label are how the "agent can see" UI names it.
936
+ const descriptor = this.GetDescriptor();
937
+ this.VisualVideoBridge = new ChannelInboundVideoBridge(() => this.Context?.Client, this.visualFrameProvider, {
938
+ SourceID: ChannelVideoSourceID(descriptor.Key, this.InstanceId),
939
+ Label: descriptor.DisplayName,
940
+ Kind: 'surface',
941
+ ChannelKey: descriptor.Key,
942
+ });
943
+ // The pump sends only on change, and a frame the arbiter refused (another source had the model's only stream, or
944
+ // this one was off) never reached the model: when the arbiter sends this source again, send the current picture.
945
+ this.stopRefreshOnSourceActivated = this.VisualVideoBridge.OnSourceActivated(() => {
946
+ void this.visualFramePump?.Refresh();
947
+ });
948
+ if (this.Exposure !== 'pixels') {
949
+ this.VisualVideoBridge.SetSourceEnabled?.(false, false);
950
+ }
951
+ }
952
+ // List the source with the arbiter as soon as the video track is up, so the user can see and switch it
953
+ // before it has sent a frame (a source switched off never sends one, and would otherwise be unlistable).
954
+ // (Optional call: channels' tests install minimal bridge stubs that predate source registration, as with Start below.)
955
+ this.VisualVideoBridge?.Register?.();
956
+ if (this.visualPerceptionOptions.StartBridgePoller &&
957
+ this.VisualVideoBridge &&
958
+ !this.VisualVideoBridge.IsActive &&
959
+ this.Context?.Client?.IsTrackEstablished('video', 'inbound')) {
960
+ this.VisualVideoBridge.Start?.();
961
+ }
962
+ return this.VisualVideoBridge;
963
+ }
964
+ /** Builds the pump, wiring it to this channel's context, bridge and event stream. */
965
+ createVisualPump(provider) {
966
+ return new VisualPerceptionPump({
967
+ GetSink: () => this.EnsureVideoBridge(),
968
+ IsInboundVideoEstablished: () => this.Context?.Client?.IsTrackEstablished('video', 'inbound') ?? false,
969
+ IsPermitted: () => this.Exposure === 'pixels',
970
+ GetCadenceMs: () => this.getNegotiatedVisualCadenceMs(),
971
+ CaptureFrame: async () => provider.GetLatestFrame(),
972
+ GetChangeId: () => this.channelChangeSeq,
973
+ OnFramePushed: (frame, reason, changeId) => this.EmitChannelEvent('frame_pushed', { reason, bytes: frame.length }, changeId),
974
+ SendConfirmationNote: (changeId) => this.Context?.SendContextNote(this.confirmationNote(changeId)),
975
+ OnError: (context, error) => console.error(`[RealtimeChannel:${this.ChannelName}] Error in the visual ${context} path:`, error)
976
+ });
977
+ }
978
+ /** The "do not narrate your own change" note sent with a confirmation frame. */
979
+ confirmationNote(changeId) {
980
+ const configured = this.visualPerceptionOptions.ConfirmationNote;
981
+ if (typeof configured === 'string') {
982
+ return configured;
983
+ }
984
+ if (typeof configured === 'function') {
985
+ return configured(changeId);
986
+ }
987
+ return FormatChannelNote(this.ChannelName, this.InstanceId, 'frame_confirmed', {
988
+ changeId,
989
+ guidance: 'visual confirmation of your action (background — do NOT narrate or announce your own change; continue naturally)'
990
+ });
991
+ }
992
+ /**
993
+ * The frame cadence in ms: the rate the inbound video track negotiated (`1000 / Rate`), clamped to
994
+ * a 250ms floor; 1000ms when nothing was negotiated.
995
+ */
996
+ getNegotiatedVisualCadenceMs() {
997
+ const tracks = this.Context?.Client?.EstablishedTracks;
998
+ const rate = tracks?.find((t) => t.Descriptor.Modality === 'video' && t.Descriptor.Direction === 'inbound')?.Descriptor.Rate;
999
+ if (typeof rate === 'number' && rate > 0) {
1000
+ return Math.max(BaseRealtimeChannelClient.MIN_VISUAL_CADENCE_MS, Math.floor(1000 / rate));
1001
+ }
1002
+ return BaseRealtimeChannelClient.DEFAULT_VISUAL_CADENCE_MS;
1003
+ }
222
1004
  /**
223
1005
  * Tears the plugin down at session end: release the surface binding, unsubscribe
224
1006
  * state-engine subscriptions, then drop the context. Subclasses overriding this MUST
@@ -227,7 +1009,79 @@ export class BaseRealtimeChannelClient {
227
1009
  */
228
1010
  Dispose() {
229
1011
  this.UnbindSurface();
1012
+ this.channelPerception?.Dispose();
1013
+ this.channelPerception = null;
1014
+ this.visualFramePump?.Dispose();
1015
+ this.visualFramePump = null;
1016
+ this.stopRefreshOnSourceActivated?.();
1017
+ this.stopRefreshOnSourceActivated = null;
1018
+ this.VisualVideoBridge?.Stop();
1019
+ this.VisualVideoBridge = null;
1020
+ this.channelEventsSubject.complete();
1021
+ this.channelOutputSubject.complete();
230
1022
  this.Context = null;
231
1023
  }
232
1024
  }
1025
+ /** The primary instance id of a single-instance channel. */
1026
+ const PRIMARY_CHANNEL_INSTANCE_ID = '1';
1027
+ /**
1028
+ * The id of a channel's video source at the session's `VideoSourceArbiter`: `<channel key>#<instance id>`. The channel's
1029
+ * own frame bridge and the runtime's {@link RealtimeChannelContext.SendVideoFrame} register under it, so a channel is
1030
+ * one source however it sends, and the "agent can see" control lists it once.
1031
+ *
1032
+ * @param channelKey The channel's descriptor key.
1033
+ * @param instanceId The channel instance.
1034
+ */
1035
+ export function ChannelVideoSourceID(channelKey, instanceId) {
1036
+ return `${channelKey}#${instanceId}`;
1037
+ }
1038
+ /** True when a Node-style environment reports a non-production `NODE_ENV` (never in a plain browser). */
1039
+ function isDevelopmentEnvironment() {
1040
+ return typeof process !== 'undefined' && !!process.env && process.env['NODE_ENV'] !== undefined && process.env['NODE_ENV'] !== 'production';
1041
+ }
1042
+ /** Tolerantly parses native tool arguments into a plain object (`{}` for anything else). */
1043
+ function parseToolArguments(argsJson) {
1044
+ try {
1045
+ const parsed = argsJson ? JSON.parse(argsJson) : {};
1046
+ // JSON.parse output is JSON by construction, so narrowing it to JSONObject is not a type lie.
1047
+ return IsPlainObject(parsed) ? parsed : {};
1048
+ }
1049
+ catch {
1050
+ return {};
1051
+ }
1052
+ }
1053
+ /** Serializes a structured verb result for the native tool path (the model reads this JSON). */
1054
+ function serializeVerbResult(result) {
1055
+ if (result.Success) {
1056
+ return JSON.stringify(result.Result === undefined ? { success: true } : { success: true, result: result.Result });
1057
+ }
1058
+ const failure = { success: false, error: result.Error ?? 'The action could not be performed.' };
1059
+ if (result.ErrorCode) {
1060
+ failure['errorCode'] = result.ErrorCode;
1061
+ }
1062
+ if (result.Details) {
1063
+ failure['details'] = result.Details;
1064
+ }
1065
+ return JSON.stringify(failure);
1066
+ }
1067
+ /**
1068
+ * Adapts a legacy tool result string to a structured {@link RealtimeChannelVerbResult}: a JSON object
1069
+ * with `success: false` is a failure (its `error`/`output`/`message` becomes the message); any other
1070
+ * JSON is the result; a non-JSON string is the result as text.
1071
+ */
1072
+ function parseLegacyVerbResult(resultJson) {
1073
+ let parsed;
1074
+ try {
1075
+ parsed = JSON.parse(resultJson);
1076
+ }
1077
+ catch {
1078
+ return { Success: true, Result: resultJson };
1079
+ }
1080
+ if (IsPlainObject(parsed) && parsed['success'] === false) {
1081
+ const message = [parsed['error'], parsed['output'], parsed['message']].find((m) => typeof m === 'string' && m.length > 0);
1082
+ return { Success: false, ErrorCode: 'verb_failed', Error: message ?? 'The action could not be performed.', Result: parsed };
1083
+ }
1084
+ // JSON.parse output is JSON by construction, so narrowing it to JSONValue is not a type lie.
1085
+ return { Success: true, Result: parsed };
1086
+ }
233
1087
  //# sourceMappingURL=base-realtime-channel-client.js.map