@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.
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +5 -1
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +15 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +53 -11
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1180 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35564 -29262
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +8 -0
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionJanitor.ts +15 -1
- package/src/agentSessions/SessionManager.ts +62 -10
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17659 -13322
- package/src/index.ts +15 -1
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/RunTemplateResolver.ts +8 -0
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- 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(
|
|
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 = {
|
|
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
|
-
|
|
412
|
-
|
|
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
|
/**
|