@memberjunction/server 5.42.0 → 5.44.0

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 (101) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +53 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts.map +1 -1
  9. package/dist/agentSessions/index.js +3 -1
  10. package/dist/agentSessions/index.js.map +1 -1
  11. package/dist/generated/generated.d.ts +1180 -51
  12. package/dist/generated/generated.d.ts.map +1 -1
  13. package/dist/generated/generated.js +35564 -29262
  14. package/dist/generated/generated.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +14 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  20. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  21. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  22. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  23. package/dist/resolvers/FileResolver.d.ts +74 -0
  24. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  25. package/dist/resolvers/FileResolver.js +211 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  30. package/dist/resolvers/QueryResolver.d.ts +6 -4
  31. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QueryResolver.js +29 -14
  33. package/dist/resolvers/QueryResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
  35. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
  37. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  38. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  39. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  40. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  41. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  42. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  43. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  44. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  45. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  46. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  48. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  49. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  50. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunTemplateResolver.js +8 -0
  52. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  53. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  54. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  55. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  56. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  57. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  58. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  59. package/dist/resolvers/peaksSidecar.js +51 -0
  60. package/dist/resolvers/peaksSidecar.js.map +1 -0
  61. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  62. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  63. package/dist/rest/MediaAccessKeys.js +96 -0
  64. package/dist/rest/MediaAccessKeys.js.map +1 -0
  65. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  66. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  67. package/dist/rest/MediaStreamHandler.js +192 -0
  68. package/dist/rest/MediaStreamHandler.js.map +1 -0
  69. package/dist/rest/mediaRange.d.ts +41 -0
  70. package/dist/rest/mediaRange.d.ts.map +1 -0
  71. package/dist/rest/mediaRange.js +60 -0
  72. package/dist/rest/mediaRange.js.map +1 -0
  73. package/package.json +83 -82
  74. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  75. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  76. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  77. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  78. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  79. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  80. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  81. package/src/__tests__/SessionManager.test.ts +2 -0
  82. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  83. package/src/agentSessions/SessionJanitor.ts +15 -1
  84. package/src/agentSessions/SessionManager.ts +62 -10
  85. package/src/agentSessions/index.ts +3 -1
  86. package/src/generated/generated.ts +17659 -13322
  87. package/src/index.ts +15 -1
  88. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  89. package/src/resolvers/FileResolver.ts +199 -1
  90. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  91. package/src/resolvers/QueryResolver.ts +33 -19
  92. package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
  93. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  94. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  95. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  96. package/src/resolvers/RunTemplateResolver.ts +8 -0
  97. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  98. package/src/resolvers/peaksSidecar.ts +52 -0
  99. package/src/rest/MediaAccessKeys.ts +121 -0
  100. package/src/rest/MediaStreamHandler.ts +220 -0
  101. package/src/rest/mediaRange.ts +76 -0
@@ -24,11 +24,12 @@
24
24
  *
25
25
  * @module @memberjunction/server
26
26
  */
27
- import { Resolver, Mutation, Arg, Ctx, Int, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
27
+ import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
28
  import { AppContext, UserPayload } from '../types.js';
29
29
  import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
30
- import { UUIDsEqual } from '@memberjunction/global';
30
+ import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
31
  import {
32
+ MJAIAgentEntity,
32
33
  MJAIAgentSessionEntity,
33
34
  MJAIAgentSessionChannelEntity,
34
35
  MJArtifactEntity,
@@ -46,9 +47,14 @@ import {
46
47
  EvaluateRuntimeOverrideAuthorization,
47
48
  ParseRealtimeTypeConfiguration,
48
49
  ResolveEffectiveRealtimeConfig,
50
+ RealtimeAllowedAgent,
49
51
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
52
+ resolveRecordingStorageAccountID,
53
+ storeRealtimeRecording,
54
+ writeRealtimeRecordingSegment,
55
+ deleteRealtimeRecordingSegments,
50
56
  } from '@memberjunction/ai-agents';
51
- import { AgentExecutionProgressCallback, MJAIAgentEntityExtended } from '@memberjunction/ai-core-plus';
57
+ import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
52
58
  import { RealtimeToolDefinition } from '@memberjunction/ai';
53
59
  import { ResolverBase } from '../generic/ResolverBase.js';
54
60
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
@@ -155,6 +161,24 @@ interface RealtimeSessionConfig {
155
161
  * rather than starting fresh. Re-stored if the resumed run pauses again.
156
162
  */
157
163
  pendingFeedbackRunID?: string;
164
+ /**
165
+ * The application the session runs in (sourced the app cascade layer + RelevantAgents). Persisted
166
+ * for observability / continuity; absent when the session carries no app context.
167
+ */
168
+ applicationID?: string;
169
+ /**
170
+ * The session's effective allowed delegation targets (union from the config cascade, incl. the app's
171
+ * `RelevantAgents`). Persisted at start so each relayed `invoke-target-agent` call can validate a
172
+ * model-named colleague against it without re-resolving the cascade. Absent ⇒ single-target behavior.
173
+ */
174
+ allowedAgents?: RealtimeAllowedAgent[];
175
+ /**
176
+ * Per-session override for the agent's media kit — a `MJ: Collections` id that takes precedence
177
+ * over the agent's `DefaultMediaCollectionID` when the server-side `MediaChannelServer` resolves
178
+ * the media manifest at session start. Validated as a UUID at the mutation boundary; absent ⇒ the
179
+ * agent default kit is used (the common case).
180
+ */
181
+ mediaCollectionID?: string;
158
182
  }
159
183
 
160
184
  /**
@@ -289,6 +313,27 @@ export class CancelRealtimeSessionToolResult {
289
313
  ErrorMessage?: string;
290
314
  }
291
315
 
316
+ /**
317
+ * Result of {@link RealtimeClientSessionResolver.UploadRealtimeRecording} — a structured
318
+ * success/failure envelope. A recording-storage failure must never throw to the browser (the
319
+ * session it belongs to has already ended), so problems come back as `Success: false` with a
320
+ * human-readable reason; only ownership/authn violations (from `loadOwnedSession`) still throw.
321
+ */
322
+ @ObjectType()
323
+ export class UploadRealtimeRecordingResult {
324
+ /** True when the audio was stored and the session was stamped with the recording file. */
325
+ @Field(() => Boolean)
326
+ Success: boolean;
327
+
328
+ /** Human-readable failure reason. Null on success. */
329
+ @Field(() => String, { nullable: true })
330
+ ErrorMessage?: string;
331
+
332
+ /** ID of the created `MJ: Files` row holding the uploaded recording. Null on failure. */
333
+ @Field(() => String, { nullable: true })
334
+ FileID?: string;
335
+ }
336
+
292
337
  /**
293
338
  * Resolver for the client-direct realtime voice topology. A single {@link SessionManager} and a
294
339
  * single {@link RealtimeClientSessionService} are shared across requests — neither holds per-user or
@@ -348,6 +393,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
348
393
  @Arg('clientToolsJson', () => String, { nullable: true }) clientToolsJson?: string,
349
394
  @Arg('coAgentId', () => String, { nullable: true }) coAgentId?: string,
350
395
  @Arg('configOverridesJson', () => String, { nullable: true }) configOverridesJson?: string,
396
+ @Arg('recordingStartedAt', () => String, { nullable: true }) recordingStartedAt?: string,
397
+ @Arg('recordingConsent', () => Boolean, { nullable: true }) recordingConsent?: boolean,
398
+ @Arg('mediaCollectionId', () => String, { nullable: true }) mediaCollectionId?: string,
399
+ @Arg('applicationId', () => String, { nullable: true }) applicationId?: string,
400
+ @Arg('appContextJson', () => String, { nullable: true }) appContextJson?: string,
351
401
  ): Promise<StartRealtimeClientSessionResult> {
352
402
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
353
403
 
@@ -363,6 +413,20 @@ export class RealtimeClientSessionResolver extends ResolverBase {
363
413
  await this.assertRuntimeOverridesAuthorized(coAgentID, configOverridesJson, preferredModelId, contextUser, provider);
364
414
 
365
415
  const config: RealtimeSessionConfig = { targetAgentID: effectiveTargetId };
416
+ // Per-session media-kit override: stored on the session config so the server-side MediaChannelServer
417
+ // resolves it (over the agent default) at start. Validate the id here — a malformed value is
418
+ // dropped (logged), never interpolated; the session still starts and falls back to the agent kit.
419
+ const mediaOverride = mediaCollectionId?.trim();
420
+ if (mediaOverride) {
421
+ if (IsValidUUID(mediaOverride)) {
422
+ config.mediaCollectionID = mediaOverride;
423
+ } else {
424
+ LogStatus(
425
+ `StartRealtimeClientSession: ignoring malformed mediaCollectionId '${mediaCollectionId}' — ` +
426
+ 'using the agent default media kit.',
427
+ );
428
+ }
429
+ }
366
430
  const session = await this.sessionManager.CreateSession(
367
431
  {
368
432
  agentID: coAgentID,
@@ -375,6 +439,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
375
439
  provider,
376
440
  );
377
441
 
442
+ // Best-effort: stamp recording-start metadata when the browser captured (with consent) at start.
443
+ await this.stampRecordingStart(session, recordingConsent, recordingStartedAt);
444
+
378
445
  const clientTools = this.parseClientTools(clientToolsJson);
379
446
  // Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
380
447
  // checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
@@ -382,7 +449,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
382
449
  const priorTranscript = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
383
450
  const result = await this.prepareClientSessionOrClose(
384
451
  session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, priorTranscript,
385
- configOverridesJson,
452
+ configOverridesJson, applicationId, this.parseAppContext(appContextJson),
386
453
  );
387
454
  // Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
388
455
  // board). Strictly tolerant — any problem yields a null field, never a failed start.
@@ -416,6 +483,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
416
483
  {
417
484
  AgentSessionID: agentSessionId,
418
485
  TargetAgentID: config.targetAgentID,
486
+ // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
487
+ // colleague in the call is validated against this; absent ⇒ single-target behavior.
488
+ AllowedAgents: config.allowedAgents,
419
489
  // Nest the delegated target-agent run under the co-agent observability run (when present).
420
490
  ParentRunID: config.coAgentRunID,
421
491
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
@@ -492,6 +562,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
492
562
  promptRunID: config.promptRunID,
493
563
  coAgentRunStepID: config.coAgentRunStepID,
494
564
  pendingFeedbackRunID: pausedRunID,
565
+ applicationID: config.applicationID,
566
+ allowedAgents: config.allowedAgents,
495
567
  };
496
568
  session.Config_ = JSON.stringify(next);
497
569
  if (!(await session.Save())) {
@@ -521,13 +593,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
521
593
  @Arg('text', () => String) text: string,
522
594
  @Ctx() { userPayload, providers }: AppContext,
523
595
  @Arg('replacesPrevious', () => Boolean, { nullable: true }) replacesPrevious?: boolean,
596
+ @Arg('utteranceStartMs', () => Int, { nullable: true }) utteranceStartMs?: number,
597
+ @Arg('utteranceEndMs', () => Int, { nullable: true }) utteranceEndMs?: number,
524
598
  ): Promise<boolean> {
525
599
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
526
600
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
527
601
 
528
602
  const saved = replacesPrevious
529
- ? await this.replacePreviousTranscriptTurn(session, role, text, contextUser, provider)
530
- : await this.persistTranscriptTurn(session, role, text, contextUser, provider);
603
+ ? await this.replacePreviousTranscriptTurn(session, role, text, contextUser, provider, utteranceStartMs, utteranceEndMs)
604
+ : await this.persistTranscriptTurn(session, role, text, contextUser, provider, utteranceStartMs, utteranceEndMs);
531
605
  if (!saved) {
532
606
  return false;
533
607
  }
@@ -557,6 +631,145 @@ export class RealtimeClientSessionResolver extends ResolverBase {
557
631
  return role.trim().toLowerCase() === 'user' ? 'user' : 'assistant';
558
632
  }
559
633
 
634
+ /**
635
+ * Upload a CLIENT-DIRECT session audio recording, store it in MJStorage, link it to the owning
636
+ * `AIAgentSession`, and stamp the session's recording fields. The browser records locally during
637
+ * the call and uploads the assembled blob (base64) once the session ends.
638
+ *
639
+ * Hard gates (in order):
640
+ * 1. Ownership — `loadOwnedSession` enforces `UserID === contextUser.ID` (throws on violation).
641
+ * 2. Consent — `consent !== true` is a hard refusal: no audio is ever stored without it.
642
+ * 3. Storage configuration — the session's agent must resolve a recording storage account (the
643
+ * agent's `RecordingStorageProviderID`, else `AttachmentStorageProviderID`).
644
+ *
645
+ * Storage failures (and any unexpected throw) come back as `Success: false` with a reason — they
646
+ * NEVER throw to the browser, mirroring the shared {@link storeRealtimeRecording} contract.
647
+ *
648
+ * @param agentSessionId The session the recording belongs to (ownership-gated).
649
+ * @param audioBase64 The base64-encoded recording bytes (client-direct: seekable WAV/PCM).
650
+ * @param mimeType The audio MIME type (`audio/wav` for client-direct; legacy `audio/webm`/`ogg`/`mp4`).
651
+ * @param durationMs Optional client-measured recording duration (ms) — informational.
652
+ * @param consent Whether the user consented to recording. MUST be `true` or the upload is refused.
653
+ * @param peaks Optional capture-time waveform peaks (max-abs per bucket, normalized 0..1). When
654
+ * present, persisted as a `peaks.json` sidecar beside the recording for fast waveform rendering
655
+ * without re-decoding the audio.
656
+ * @returns A structured {@link UploadRealtimeRecordingResult} with the created `MJ: Files` id.
657
+ */
658
+ @Mutation(() => UploadRealtimeRecordingResult)
659
+ async UploadRealtimeRecording(
660
+ @Arg('agentSessionId', () => String) agentSessionId: string,
661
+ @Arg('audioBase64', () => String) audioBase64: string,
662
+ @Arg('mimeType', () => String) mimeType: string,
663
+ @Ctx() ctx: AppContext,
664
+ @Arg('durationMs', () => Int, { nullable: true }) durationMs?: number,
665
+ @Arg('consent', () => Boolean, { nullable: true }) consent?: boolean,
666
+ @Arg('peaks', () => [Float], { nullable: true }) peaks?: number[],
667
+ ): Promise<UploadRealtimeRecordingResult> {
668
+ const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
669
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
670
+
671
+ // HARD consent gate — no audio is ever stored without an explicit grant.
672
+ if (consent !== true) {
673
+ return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
674
+ }
675
+
676
+ try {
677
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
678
+ if (!(await agent.Load(session.AgentID))) {
679
+ return { Success: false, ErrorMessage: `Co-agent ${session.AgentID} for the session could not be loaded.` };
680
+ }
681
+
682
+ const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
683
+ if (!accountID) {
684
+ return { Success: false, ErrorMessage: 'No recording storage account is configured for this agent.' };
685
+ }
686
+
687
+ const buffer = Buffer.from(audioBase64, 'base64');
688
+ if (buffer.length === 0) {
689
+ return { Success: false, ErrorMessage: 'The uploaded recording was empty.' };
690
+ }
691
+
692
+ const fileID = await storeRealtimeRecording({
693
+ Audio: buffer,
694
+ MimeType: mimeType,
695
+ Media: 'Audio',
696
+ StartedAt: session.RecordingStartedAt ?? new Date(),
697
+ StorageAccountID: accountID,
698
+ SessionID: agentSessionId,
699
+ ContextUser: contextUser,
700
+ Provider: provider,
701
+ // Sanitized capture-time waveform peaks → persisted as a peaks.json sidecar.
702
+ Peaks: this.sanitizePeaks(peaks),
703
+ });
704
+
705
+ // Canonical consolidated file written — drop the crash-recovery shards (best-effort).
706
+ if (fileID) {
707
+ await deleteRealtimeRecordingSegments(agentSessionId, accountID, contextUser);
708
+ }
709
+
710
+ return {
711
+ Success: !!fileID,
712
+ FileID: fileID ?? undefined,
713
+ ErrorMessage: fileID ? undefined : 'Storage upload failed.',
714
+ };
715
+ } catch (error) {
716
+ const message = error instanceof Error ? error.message : String(error);
717
+ LogError(`RealtimeClientSessionResolver.UploadRealtimeRecording failed for session ${agentSessionId}: ${message}`);
718
+ return { Success: false, ErrorMessage: message };
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Uploads ONE crash-recovery audio shard (~15s) for an IN-PROGRESS recording into the session's
724
+ * folder (`realtime-recordings/<sessionId>/seg-NNNN.<ext>`) as a raw storage object. Durability
725
+ * insurance during a live call so a browser/tab death loses at most the last window; the shards are
726
+ * deleted once the canonical consolidated file lands via {@link UploadRealtimeRecording}. Ownership-
727
+ * gated; consent was already established at session start. Best-effort — returns `false` (never
728
+ * throws) on any problem so a failed shard never disrupts the live call.
729
+ *
730
+ * @param agentSessionId The in-progress session (ownership-gated).
731
+ * @param segmentIndex 0-based shard index within the session.
732
+ * @param audioBase64 The base64-encoded shard bytes.
733
+ * @param mimeType The audio MIME type.
734
+ * @returns `true` when the shard was stored.
735
+ */
736
+ @Mutation(() => Boolean)
737
+ async UploadRealtimeRecordingSegment(
738
+ @Arg('agentSessionId', () => String) agentSessionId: string,
739
+ @Arg('segmentIndex', () => Int) segmentIndex: number,
740
+ @Arg('audioBase64', () => String) audioBase64: string,
741
+ @Arg('mimeType', () => String) mimeType: string,
742
+ @Ctx() ctx: AppContext,
743
+ ): Promise<boolean> {
744
+ try {
745
+ const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
746
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
747
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
748
+ if (!(await agent.Load(session.AgentID))) {
749
+ return false;
750
+ }
751
+ const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
752
+ if (!accountID) {
753
+ return false;
754
+ }
755
+ const buffer = Buffer.from(audioBase64, 'base64');
756
+ if (buffer.length === 0) {
757
+ return false;
758
+ }
759
+ return await writeRealtimeRecordingSegment({
760
+ SessionID: agentSessionId,
761
+ SegmentIndex: segmentIndex,
762
+ Audio: buffer,
763
+ MimeType: mimeType,
764
+ StorageAccountID: accountID,
765
+ ContextUser: contextUser,
766
+ });
767
+ } catch (error) {
768
+ LogError(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment failed for session ${agentSessionId}: ${error instanceof Error ? error.message : String(error)}`);
769
+ return false;
770
+ }
771
+ }
772
+
560
773
  /**
561
774
  * Relays a co-agent CHANNEL tool-call (browser_ / Whiteboard_ etc.) onto the session's co-agent
562
775
  * AIPromptRun.Messages — run-only observability so the run captures what the co-agent DID, not just
@@ -690,6 +903,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
690
903
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
691
904
  }
692
905
 
906
+ /**
907
+ * Sanitizes client-supplied waveform peaks before they're persisted: drops anything that isn't a
908
+ * finite number, clamps each to `[0, 1]`, and caps the array length so a hostile client can't bloat
909
+ * the sidecar. Returns `undefined` for a missing/empty array (no sidecar written).
910
+ */
911
+ private sanitizePeaks(peaks: number[] | undefined): number[] | undefined {
912
+ if (!Array.isArray(peaks) || peaks.length === 0) {
913
+ return undefined;
914
+ }
915
+ const MAX_PEAKS = 4096; // generous ceiling above the client's ~600-bucket target
916
+ const cleaned = peaks
917
+ .slice(0, MAX_PEAKS)
918
+ .filter((v) => Number.isFinite(v))
919
+ .map((v) => (v < 0 ? 0 : v > 1 ? 1 : v));
920
+ return cleaned.length > 0 ? cleaned : undefined;
921
+ }
922
+
693
923
  /**
694
924
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
695
925
  * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
@@ -1305,6 +1535,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1305
1535
  clientTools?: RealtimeToolDefinition[],
1306
1536
  priorTranscript?: string,
1307
1537
  configOverridesJson?: string,
1538
+ applicationId?: string,
1539
+ appContext?: AppContextSnapshot,
1308
1540
  ): Promise<StartRealtimeClientSessionResult> {
1309
1541
  const prep = await this.clientSessionService.PrepareClientSession(
1310
1542
  {
@@ -1325,6 +1557,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1325
1557
  PriorTranscript: priorTranscript,
1326
1558
  // Pre-authorized runtime override layer (assertRuntimeOverridesAuthorized gated it).
1327
1559
  ConfigOverridesJson: configOverridesJson,
1560
+ // App awareness (Move 1/3/4): the app sources the app cascade layer + RelevantAgents
1561
+ // (allowed-agent union); the snapshot is injected into the system prompt at mint.
1562
+ ApplicationID: applicationId,
1563
+ AppContext: appContext,
1328
1564
  },
1329
1565
  contextUser,
1330
1566
  provider,
@@ -1336,7 +1572,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1336
1572
  throw new Error(prep.ErrorMessage ?? 'Failed to prepare the client realtime session.');
1337
1573
  }
1338
1574
 
1339
- await this.persistObservabilityRunIDs(session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID);
1575
+ await this.persistObservabilityRunIDs(
1576
+ session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID,
1577
+ applicationId, prep.EffectiveConfig?.realtime?.allowedAgents,
1578
+ );
1340
1579
 
1341
1580
  const cfg = prep.ClientConfig;
1342
1581
  return {
@@ -1366,8 +1605,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1366
1605
  coAgentRunID?: string,
1367
1606
  promptRunID?: string,
1368
1607
  coAgentRunStepID?: string,
1608
+ applicationID?: string,
1609
+ allowedAgents?: RealtimeAllowedAgent[],
1369
1610
  ): Promise<void> {
1370
- const config: RealtimeSessionConfig = { targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID };
1611
+ const config: RealtimeSessionConfig = {
1612
+ targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID, applicationID,
1613
+ allowedAgents: allowedAgents && allowedAgents.length > 0 ? allowedAgents : undefined,
1614
+ };
1371
1615
  session.Config_ = JSON.stringify(config);
1372
1616
  const saved = await session.Save();
1373
1617
  if (!saved) {
@@ -1997,6 +2241,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1997
2241
  }
1998
2242
  }
1999
2243
 
2244
+ /**
2245
+ * Tolerantly parses the `appContextJson` mutation arg into an {@link AppContextSnapshot}. Returns
2246
+ * `undefined` for absent/blank/malformed/non-object payloads — app context is best-effort enrichment
2247
+ * and must never fail a session start.
2248
+ *
2249
+ * @param appContextJson The raw JSON string the browser sent, or undefined.
2250
+ * @returns The parsed snapshot, or `undefined`.
2251
+ */
2252
+ private parseAppContext(appContextJson?: string): AppContextSnapshot | undefined {
2253
+ if (!appContextJson || !appContextJson.trim()) {
2254
+ return undefined;
2255
+ }
2256
+ try {
2257
+ const parsed: unknown = JSON.parse(appContextJson);
2258
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
2259
+ ? (parsed as AppContextSnapshot)
2260
+ : undefined;
2261
+ } catch {
2262
+ return undefined;
2263
+ }
2264
+ }
2265
+
2000
2266
  /**
2001
2267
  * Tolerantly parses + validates client-declared UI tool definitions (see the SECURITY NOTE on
2002
2268
  * {@link StartRealtimeClientSession}). Never throws — any rejection logs and returns
@@ -2069,6 +2335,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2069
2335
  coAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
2070
2336
  pendingFeedbackRunID:
2071
2337
  typeof parsed.pendingFeedbackRunID === 'string' ? parsed.pendingFeedbackRunID : undefined,
2338
+ applicationID: typeof parsed.applicationID === 'string' ? parsed.applicationID : undefined,
2339
+ allowedAgents: Array.isArray(parsed.allowedAgents) ? parsed.allowedAgents : undefined,
2072
2340
  };
2073
2341
  }
2074
2342
  } catch {
@@ -2084,12 +2352,45 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2084
2352
  *
2085
2353
  * @returns The boolean save result (logs `CompleteMessage` on failure).
2086
2354
  */
2355
+ /**
2356
+ * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2357
+ * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
2358
+ * is logged and swallowed — a recording-metadata problem must never fail the session start.
2359
+ *
2360
+ * @param session The freshly created/loaded session.
2361
+ * @param recordingConsent Whether the user consented to recording — only `true` stamps anything.
2362
+ * @param recordingStartedAt ISO-8601 recording start timestamp from the browser.
2363
+ */
2364
+ private async stampRecordingStart(
2365
+ session: MJAIAgentSessionEntity,
2366
+ recordingConsent: boolean | undefined,
2367
+ recordingStartedAt: string | undefined,
2368
+ ): Promise<void> {
2369
+ if (recordingConsent !== true || !recordingStartedAt) {
2370
+ return;
2371
+ }
2372
+ const startedAt = new Date(recordingStartedAt);
2373
+ if (Number.isNaN(startedAt.getTime())) {
2374
+ LogError(`RealtimeClientSessionResolver.stampRecordingStart: invalid recordingStartedAt '${recordingStartedAt}' for session ${session.ID}`);
2375
+ return;
2376
+ }
2377
+ session.RecordingStartedAt = startedAt;
2378
+ session.RecordingMedia = 'Audio';
2379
+ if (!(await session.Save())) {
2380
+ LogError(
2381
+ `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2382
+ );
2383
+ }
2384
+ }
2385
+
2087
2386
  private async persistTranscriptTurn(
2088
2387
  session: MJAIAgentSessionEntity,
2089
2388
  role: string,
2090
2389
  text: string,
2091
2390
  contextUser: UserInfo,
2092
2391
  provider: IMetadataProvider,
2392
+ utteranceStartMs?: number,
2393
+ utteranceEndMs?: number,
2093
2394
  ): Promise<boolean> {
2094
2395
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2095
2396
  CONVERSATION_DETAIL_ENTITY,
@@ -2101,6 +2402,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2101
2402
  detail.Message = text;
2102
2403
  detail.AgentSessionID = session.ID;
2103
2404
  detail.UserID = contextUser.ID;
2405
+ if (typeof utteranceStartMs === 'number' && utteranceStartMs >= 0) {
2406
+ detail.UtteranceStartMs = utteranceStartMs;
2407
+ }
2408
+ if (typeof utteranceEndMs === 'number' && utteranceEndMs >= 0) {
2409
+ detail.UtteranceEndMs = utteranceEndMs;
2410
+ }
2104
2411
 
2105
2412
  const saved = await detail.Save();
2106
2413
  if (!saved) {
@@ -2124,6 +2431,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2124
2431
  text: string,
2125
2432
  contextUser: UserInfo,
2126
2433
  provider: IMetadataProvider,
2434
+ utteranceStartMs?: number,
2435
+ utteranceEndMs?: number,
2127
2436
  ): Promise<boolean> {
2128
2437
  const mappedRole = this.mapTranscriptRole(role);
2129
2438
  const rv = RunView.FromMetadataProvider(provider);
@@ -2139,9 +2448,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2139
2448
  );
2140
2449
  const previous = result.Success ? (result.Results?.[0] ?? null) : null;
2141
2450
  if (!previous) {
2142
- return this.persistTranscriptTurn(session, role, text, contextUser, provider);
2451
+ return this.persistTranscriptTurn(session, role, text, contextUser, provider, utteranceStartMs, utteranceEndMs);
2143
2452
  }
2144
2453
  previous.Message = text;
2454
+ // The correction extends the existing turn: always refresh the end boundary when provided,
2455
+ // but only set the start when it wasn't already captured on the superseded turn.
2456
+ if (typeof utteranceEndMs === 'number' && utteranceEndMs >= 0) {
2457
+ previous.UtteranceEndMs = utteranceEndMs;
2458
+ }
2459
+ if (typeof utteranceStartMs === 'number' && utteranceStartMs >= 0 && previous.UtteranceStartMs == null) {
2460
+ previous.UtteranceStartMs = utteranceStartMs;
2461
+ }
2145
2462
  const saved = await previous.Save();
2146
2463
  if (!saved) {
2147
2464
  LogError(
@@ -408,8 +408,24 @@ export class RemoteBrowserActionResolver extends ResolverBase {
408
408
  if (!liveSession) {
409
409
  return {};
410
410
  }
411
- const screenshot = await liveSession.CaptureScreenshot();
412
- return { ScreenshotBase64: screenshot, CurrentUrl: liveSession.GetCurrentUrl() };
411
+ // The snapshot is a best-effort PERCEPTION poll (the surface fires it every ~700ms). A session
412
+ // whose underlying browser adapter has been torn down — a stale/dead handle still in the live
413
+ // map, a mid-poll teardown race, or a recycled backend container — makes CaptureScreenshot()
414
+ // throw "Browser not launched". That must degrade to an empty snapshot (the surface keeps its
415
+ // last good frame), NOT surface as a recurring GraphQL error the client logs on every tick —
416
+ // exactly as this query's contract above promises ("null rather than an error"). The live
417
+ // navigate/click path (ExecuteRemoteBrowserAction) is where a genuine browser failure is
418
+ // reported to the agent; the read-only view poll never should be.
419
+ try {
420
+ const screenshot = await liveSession.CaptureScreenshot();
421
+ return { ScreenshotBase64: screenshot, CurrentUrl: liveSession.GetCurrentUrl() };
422
+ } catch (err) {
423
+ LogError(
424
+ `[RemoteBrowserActionResolver] Snapshot capture failed for agent session ${agentSessionID} ` +
425
+ `(returning empty snapshot): ${err instanceof Error ? err.message : String(err)}`,
426
+ );
427
+ return {};
428
+ }
413
429
  }
414
430
 
415
431
  /**