@memberjunction/ai-agents 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 (104) hide show
  1. package/README.md +6 -0
  2. package/dist/AgentRunner.d.ts +49 -2
  3. package/dist/AgentRunner.d.ts.map +1 -1
  4. package/dist/AgentRunner.js +114 -21
  5. package/dist/AgentRunner.js.map +1 -1
  6. package/dist/ConversationCompactionManager.d.ts +2 -0
  7. package/dist/ConversationCompactionManager.d.ts.map +1 -1
  8. package/dist/ConversationCompactionManager.js +2 -2
  9. package/dist/ConversationCompactionManager.js.map +1 -1
  10. package/dist/ConversationToolManager.d.ts +13 -2
  11. package/dist/ConversationToolManager.d.ts.map +1 -1
  12. package/dist/ConversationToolManager.js +19 -3
  13. package/dist/ConversationToolManager.js.map +1 -1
  14. package/dist/KnowledgeAgent.d.ts.map +1 -1
  15. package/dist/KnowledgeAgent.js +6 -1
  16. package/dist/KnowledgeAgent.js.map +1 -1
  17. package/dist/MJAIAgentRequestEntityServer.d.ts +6 -0
  18. package/dist/MJAIAgentRequestEntityServer.d.ts.map +1 -1
  19. package/dist/MJAIAgentRequestEntityServer.js +26 -1
  20. package/dist/MJAIAgentRequestEntityServer.js.map +1 -1
  21. package/dist/agent-run-watchdog.d.ts +65 -5
  22. package/dist/agent-run-watchdog.d.ts.map +1 -1
  23. package/dist/agent-run-watchdog.js +143 -28
  24. package/dist/agent-run-watchdog.js.map +1 -1
  25. package/dist/agent-types/loop-agent-prompt-params.d.ts +7 -0
  26. package/dist/agent-types/loop-agent-prompt-params.d.ts.map +1 -1
  27. package/dist/agent-types/loop-agent-prompt-params.js +1 -0
  28. package/dist/agent-types/loop-agent-prompt-params.js.map +1 -1
  29. package/dist/artifact-tools/SearchResultSetToolLibrary.js +4 -4
  30. package/dist/artifact-tools/SearchResultSetToolLibrary.js.map +1 -1
  31. package/dist/base-agent.d.ts +275 -18
  32. package/dist/base-agent.d.ts.map +1 -1
  33. package/dist/base-agent.js +761 -216
  34. package/dist/base-agent.js.map +1 -1
  35. package/dist/client-tool-results.d.ts +45 -0
  36. package/dist/client-tool-results.d.ts.map +1 -0
  37. package/dist/client-tool-results.js +94 -0
  38. package/dist/client-tool-results.js.map +1 -0
  39. package/dist/index.d.ts +5 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +5 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/prior-turn-tool-result-cache.d.ts +31 -12
  44. package/dist/prior-turn-tool-result-cache.d.ts.map +1 -1
  45. package/dist/prior-turn-tool-result-cache.js +59 -16
  46. package/dist/prior-turn-tool-result-cache.js.map +1 -1
  47. package/dist/realtime/bridge-realtime-session-factory.d.ts +44 -2
  48. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
  49. package/dist/realtime/bridge-realtime-session-factory.js +68 -20
  50. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
  51. package/dist/realtime/bridge-realtime-usage-recorder.d.ts +81 -0
  52. package/dist/realtime/bridge-realtime-usage-recorder.d.ts.map +1 -0
  53. package/dist/realtime/bridge-realtime-usage-recorder.js +194 -0
  54. package/dist/realtime/bridge-realtime-usage-recorder.js.map +1 -0
  55. package/dist/realtime/client-only-channel-server.d.ts +83 -0
  56. package/dist/realtime/client-only-channel-server.d.ts.map +1 -0
  57. package/dist/realtime/client-only-channel-server.js +107 -0
  58. package/dist/realtime/client-only-channel-server.js.map +1 -0
  59. package/dist/realtime/identity-verification-channel-server.d.ts +50 -0
  60. package/dist/realtime/identity-verification-channel-server.d.ts.map +1 -0
  61. package/dist/realtime/identity-verification-channel-server.js +66 -0
  62. package/dist/realtime/identity-verification-channel-server.js.map +1 -0
  63. package/dist/realtime/interactive-component-channel-server.d.ts +39 -0
  64. package/dist/realtime/interactive-component-channel-server.d.ts.map +1 -0
  65. package/dist/realtime/interactive-component-channel-server.js +55 -0
  66. package/dist/realtime/interactive-component-channel-server.js.map +1 -0
  67. package/dist/realtime/realtime-avatar-resolution.d.ts +194 -0
  68. package/dist/realtime/realtime-avatar-resolution.d.ts.map +1 -0
  69. package/dist/realtime/realtime-avatar-resolution.js +208 -0
  70. package/dist/realtime/realtime-avatar-resolution.js.map +1 -0
  71. package/dist/realtime/realtime-channel-policy.d.ts +81 -0
  72. package/dist/realtime/realtime-channel-policy.d.ts.map +1 -0
  73. package/dist/realtime/realtime-channel-policy.js +91 -0
  74. package/dist/realtime/realtime-channel-policy.js.map +1 -0
  75. package/dist/realtime/realtime-channel-server-host.d.ts +14 -0
  76. package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -1
  77. package/dist/realtime/realtime-channel-server-host.js +35 -0
  78. package/dist/realtime/realtime-channel-server-host.js.map +1 -1
  79. package/dist/realtime/realtime-client-session-service.d.ts +430 -30
  80. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  81. package/dist/realtime/realtime-client-session-service.js +766 -111
  82. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  83. package/dist/realtime/realtime-coagent-config.d.ts +94 -15
  84. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  85. package/dist/realtime/realtime-coagent-config.js +109 -4
  86. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  87. package/dist/realtime/realtime-recording-store.d.ts +136 -16
  88. package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
  89. package/dist/realtime/realtime-recording-store.js +505 -39
  90. package/dist/realtime/realtime-recording-store.js.map +1 -1
  91. package/dist/realtime/realtime-session-runner.js +4 -4
  92. package/dist/realtime/realtime-session-runner.js.map +1 -1
  93. package/dist/realtime/realtime-vendor-resolution.d.ts +9 -0
  94. package/dist/realtime/realtime-vendor-resolution.d.ts.map +1 -1
  95. package/dist/realtime/realtime-vendor-resolution.js +16 -5
  96. package/dist/realtime/realtime-vendor-resolution.js.map +1 -1
  97. package/dist/realtime/realtime-video-output-gate.d.ts +83 -0
  98. package/dist/realtime/realtime-video-output-gate.d.ts.map +1 -0
  99. package/dist/realtime/realtime-video-output-gate.js +79 -0
  100. package/dist/realtime/realtime-video-output-gate.js.map +1 -0
  101. package/dist/tool-result-format.d.ts +14 -0
  102. package/dist/tool-result-format.d.ts.map +1 -1
  103. package/dist/tool-result-format.js.map +1 -1
  104. package/package.json +19 -19
@@ -5,9 +5,16 @@
5
5
  * recording storage account, upload the audio, link it to the `AIAgentSession`, and stamp the session's
6
6
  * recording fields. Keeps the storage policy in one place rather than duplicated per topology.
7
7
  *
8
+ * Also hosts server-side recovery ({@link RecoverRealtimeRecordingFromSegments}): rebuilding a recording
9
+ * from its crash-recovery shards when the end-of-call upload never arrived. Recovery defers to a
10
+ * recording that already exists on a best-effort basis: it re-checks the session immediately before
11
+ * its stamp, but `BaseEntity` has no compare-and-set, so a writer landing inside that one load -> save
12
+ * window can still be overwritten.
13
+ *
8
14
  * @module @memberjunction/ai-agents
9
15
  */
10
- import { LogError } from '@memberjunction/core';
16
+ import { randomBytes } from 'crypto';
17
+ import { LogError, LogStatus } from '@memberjunction/core';
11
18
  import { FileStorageEngine } from '@memberjunction/storage';
12
19
  /**
13
20
  * Resolves the storage account a session recording should be written to: the agent's
@@ -39,20 +46,41 @@ export async function ResolveRecordingStorageAccountID(agent, contextUser, provi
39
46
  export async function resolveRecordingStorageAccountID(agent, contextUser, provider) {
40
47
  return ResolveRecordingStorageAccountID(agent, contextUser, provider);
41
48
  }
42
- /** A short, stable file extension for the recording's MIME type. */
49
+ /** A short, stable file extension for the recording's MIME type (MIME types are case-insensitive). */
43
50
  function extensionForMime(mimeType) {
44
- if (mimeType.includes('webm'))
51
+ const type = mimeType.toLowerCase();
52
+ if (type.includes('webm'))
45
53
  return 'webm';
46
- if (mimeType.includes('ogg'))
54
+ if (type.includes('ogg'))
47
55
  return 'ogg';
48
- if (mimeType.includes('mp4') || mimeType.includes('m4a'))
56
+ if (type.includes('mp4') || type.includes('m4a'))
49
57
  return 'm4a';
50
58
  // Header-less raw PCM crash-recovery shards (audio/L16 / audio/pcm) — NOT individually playable;
51
59
  // recovery concatenates them in order and WAV-wraps. The consolidated file is always WAV.
52
- if (mimeType.includes('L16') || mimeType.includes('pcm'))
60
+ if (type.includes('l16') || type.includes('pcm'))
53
61
  return 'pcm';
54
62
  return 'wav';
55
63
  }
64
+ /**
65
+ * Sample rate (Hz) carried by a raw-PCM MIME type such as `audio/L16;rate=48000`. Case-insensitive on
66
+ * the type and parameter name; whitespace and extra parameters are tolerated. Returns `null` for any
67
+ * other type, a missing `rate`, or a rate that is not a positive integer. Shared by the shard-key
68
+ * writer and the recovery's content-type fallback so the two cannot disagree on what counts as a rate.
69
+ */
70
+ function parsePcmRate(mimeType) {
71
+ const [type, ...params] = mimeType.split(';').map(part => part.trim());
72
+ if (!/^audio\/(l16|pcm)$/i.test(type)) {
73
+ return null;
74
+ }
75
+ for (const param of params) {
76
+ const match = /^rate\s*=\s*(\d+)$/i.exec(param);
77
+ const rate = match ? Number(match[1]) : 0;
78
+ if (Number.isSafeInteger(rate) && rate > 0) {
79
+ return rate;
80
+ }
81
+ }
82
+ return null;
83
+ }
56
84
  /** The per-session folder all of a session's recording artifacts live in (shards + final file). */
57
85
  function recordingFolder(sessionID) {
58
86
  return `realtime-recordings/${sessionID}`;
@@ -99,12 +127,24 @@ export async function readRealtimeRecordingFile(fileID, contextUser, provider) {
99
127
  return ReadRealtimeRecordingFile(fileID, contextUser, provider);
100
128
  }
101
129
  /**
102
- * Writes ONE crash-recovery segment shard (`seg-NNNN.<ext>`) into the session's folder as a RAW
130
+ * `seg-NNNN.<ext>`, or `seg-NNNN.r<rate>.pcm` for a PCM shard whose MIME carries a rate. Header-less
131
+ * PCM has no way to say how fast to play it back, and recovery runs after the browser (and its
132
+ * `RecordingStartedAt`/rate bookkeeping) is gone, so the rate travels in the key itself.
133
+ */
134
+ function segmentFileName(segmentIndex, mimeType) {
135
+ const index = String(segmentIndex).padStart(4, '0');
136
+ const extension = extensionForMime(mimeType);
137
+ const rate = extension === 'pcm' ? parsePcmRate(mimeType) : null;
138
+ return rate === null ? `seg-${index}.${extension}` : `seg-${index}.r${rate}.${extension}`;
139
+ }
140
+ /**
141
+ * Writes ONE crash-recovery segment shard (`seg-NNNN[.r<rate>].<ext>`) into the session's folder as a RAW
103
142
  * storage object — no `MJ: Files` row, no session stamping. Shards are durability insurance during a
104
143
  * live call (so a browser/tab death loses at most the last window); they are byte-slices of one
105
144
  * continuous stream (only the first carries the container header), so they are NOT individually
106
- * playable — recovery is "concatenate the folder's shards in order". They are deleted once the
107
- * canonical consolidated file lands ({@link deleteRealtimeRecordingSegments}). Never throws.
145
+ * playable — recovery (see {@link RecoverRealtimeRecordingFromSegments}) concatenates them in index order,
146
+ * filling absent indexes with silence. They are deleted once the canonical consolidated file lands
147
+ * ({@link DeleteRealtimeRecordingSegments}), or by the recovery path once it has stamped its own file. Never throws.
108
148
  *
109
149
  * @returns `true` on success.
110
150
  */
@@ -112,7 +152,7 @@ export async function WriteRealtimeRecordingSegment(input) {
112
152
  const { SessionID, SegmentIndex, Audio, MimeType, StorageAccountID, ContextUser } = input;
113
153
  try {
114
154
  const driver = await FileStorageEngine.Instance.GetDriver(StorageAccountID, ContextUser);
115
- const name = `seg-${String(SegmentIndex).padStart(4, '0')}.${extensionForMime(MimeType)}`;
155
+ const name = segmentFileName(SegmentIndex, MimeType);
116
156
  return await driver.PutObject(`${recordingFolder(SessionID)}/${name}`, Audio, MimeType);
117
157
  }
118
158
  catch (error) {
@@ -151,8 +191,10 @@ export async function writeRecordingPeaksSidecar(sessionID, storageAccountID, pe
151
191
  return WriteRecordingPeaksSidecar(sessionID, storageAccountID, peaks, contextUser);
152
192
  }
153
193
  /**
154
- * Deletes the `seg-*` shards in a session's folder, leaving the consolidated `recording.*` file. Called
155
- * after {@link storeRealtimeRecording} writes the canonical file at end of call. Never throws.
194
+ * Deletes every `seg-*` shard in a session's folder, leaving the consolidated `recording.*` file. Called
195
+ * after {@link StoreRealtimeRecording} writes the canonical file at end of call. The recovery path
196
+ * ({@link RecoverRealtimeRecordingFromSegments}) does not use this: it deletes only the shards it listed
197
+ * and assembled, so a shard written after its listing is not lost. Never throws.
156
198
  *
157
199
  * @returns The number of shards deleted.
158
200
  */
@@ -160,7 +202,9 @@ export async function DeleteRealtimeRecordingSegments(sessionID, storageAccountI
160
202
  try {
161
203
  const driver = await FileStorageEngine.Instance.GetDriver(storageAccountID, contextUser);
162
204
  const folder = recordingFolder(sessionID);
163
- const listed = await driver.ListObjects(folder);
205
+ // List the folder's CONTENTS: without the trailing slash, delimiter-based drivers (S3, GCS)
206
+ // return the folder itself as a common prefix and no objects, so no shard was ever deleted.
207
+ const listed = await driver.ListObjects(`${folder}/`);
164
208
  let deleted = 0;
165
209
  for (const obj of listed.objects ?? []) {
166
210
  const base = (obj.name.split('/').pop() ?? obj.name);
@@ -190,13 +234,14 @@ export async function deleteRealtimeRecordingSegments(sessionID, storageAccountI
190
234
  * @returns The created `MJ: Files` id, or the failure reason.
191
235
  */
192
236
  export async function StoreRealtimeRecording(input) {
193
- const { Audio, MimeType, Media, StartedAt, StorageAccountID, SessionID, ContextUser, Provider, Peaks } = input;
237
+ const { Audio, MimeType, StorageAccountID, SessionID, ContextUser, Provider, Peaks } = input;
194
238
  try {
195
239
  // Canonical consolidated file in the session's own folder, alongside (then replacing) its shards.
196
240
  const uploaded = await FileStorageEngine.Instance.UploadFile({
197
241
  content: Audio,
198
- fileName: `recording.${extensionForMime(MimeType)}`,
242
+ fileName: input.FileName ?? `recording.${extensionForMime(MimeType)}`,
199
243
  mimeType: MimeType,
244
+ description: input.Description,
200
245
  contextUser: ContextUser,
201
246
  storageAccountId: StorageAccountID,
202
247
  provider: Provider,
@@ -205,38 +250,459 @@ export async function StoreRealtimeRecording(input) {
205
250
  // Best-effort waveform-peaks sidecar (peaks.json) next to the recording, for fast waveform
206
251
  // rendering without re-decoding the audio. A sidecar failure never fails the recording itself.
207
252
  await WriteRecordingPeaksSidecar(SessionID, StorageAccountID, Peaks, ContextUser);
208
- // Link the file to the session record so it's discoverable via MJ: File Entity Record Links.
209
- const sessionEntityID = Provider.EntityByName('MJ: AI Agent Sessions')?.ID;
210
- if (sessionEntityID) {
211
- const link = await Provider.GetEntityObject('MJ: File Entity Record Links', ContextUser);
212
- link.NewRecord();
213
- link.FileID = uploaded.FileID;
214
- link.EntityID = sessionEntityID;
215
- link.RecordID = SessionID;
216
- if (!await link.Save()) {
217
- LogError(`storeRealtimeRecording: failed to link recording to session ${SessionID}: ${link.LatestResult?.CompleteMessage ?? 'unknown error'}`);
218
- }
219
- }
220
- // Stamp the recording fields on the session (file + media kind + t0).
221
- const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
222
- if (await session.Load(SessionID)) {
223
- session.RecordingFileID = uploaded.FileID;
224
- session.RecordingMedia = Media;
225
- session.RecordingStartedAt = StartedAt;
226
- if (!await session.Save()) {
227
- LogError(`storeRealtimeRecording: failed to stamp recording fields on session ${SessionID}: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
228
- }
229
- }
230
- return { FileID: uploaded.FileID, ErrorMessage: null };
253
+ return input.PreserveExistingRecording
254
+ ? await stampPreservingExisting(input, uploaded.FileID)
255
+ : await linkAndStamp(input, uploaded.FileID);
231
256
  }
232
257
  catch (error) {
233
258
  const message = error instanceof Error ? error.message : String(error);
234
259
  LogError(`storeRealtimeRecording failed for session ${SessionID}: ${message}`);
235
- return { FileID: null, ErrorMessage: message };
260
+ return { FileID: null, ErrorMessage: message, Superseded: false };
261
+ }
262
+ }
263
+ /**
264
+ * End-of-call path: link, then stamp unconditionally, so the browser's own recording replaces whatever
265
+ * the session pointed at. A session that cannot be loaded is still linked but not stamped (unchanged
266
+ * long-standing behaviour; the upload is reported as stored).
267
+ */
268
+ async function linkAndStamp(input, fileID) {
269
+ const { Media, StartedAt, SessionID, ContextUser, Provider } = input;
270
+ const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
271
+ const sessionLoaded = await session.Load(SessionID);
272
+ await linkRecordingToSession(fileID, SessionID, ContextUser, Provider);
273
+ if (sessionLoaded) {
274
+ await stampRecordingOnSession(session, fileID, Media, StartedAt);
275
+ }
276
+ return { FileID: fileID, ErrorMessage: null, Superseded: false };
277
+ }
278
+ /**
279
+ * Recovery path: re-check -> stamp -> link. The re-check is the last read before the stamp, so the
280
+ * window in which a late recording can still be overwritten is one load -> save. The link is written
281
+ * only once the stamp saved, so a `Superseded` or failed result never leaves a link row behind.
282
+ */
283
+ async function stampPreservingExisting(input, fileID) {
284
+ const { Media, StartedAt, SessionID, ContextUser, Provider } = input;
285
+ const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
286
+ if (!await session.Load(SessionID)) {
287
+ const message = `could not re-read session ${SessionID} before stamping; file ${fileID} is orphaned and was not linked or stamped`;
288
+ LogError(`storeRealtimeRecording: ${message}`);
289
+ return { FileID: fileID, ErrorMessage: message, Superseded: false };
290
+ }
291
+ if (session.RecordingFileID) {
292
+ LogStatus(`storeRealtimeRecording: session ${SessionID} already has recording file ${session.RecordingFileID}; the just-uploaded file ${fileID} is orphaned and was not linked or stamped`);
293
+ return { FileID: fileID, ErrorMessage: null, Superseded: true };
294
+ }
295
+ if (!await stampRecordingOnSession(session, fileID, Media, StartedAt)) {
296
+ const reason = session.LatestResult?.CompleteMessage ?? 'unknown error';
297
+ return { FileID: fileID, ErrorMessage: `could not stamp file ${fileID} on session ${SessionID} (file orphaned, not linked): ${reason}`, Superseded: false };
298
+ }
299
+ await linkRecordingToSession(fileID, SessionID, ContextUser, Provider);
300
+ return { FileID: fileID, ErrorMessage: null, Superseded: false };
301
+ }
302
+ /** Links the file to the session record so it's discoverable via MJ: File Entity Record Links. */
303
+ async function linkRecordingToSession(fileID, sessionID, contextUser, provider) {
304
+ const sessionEntityID = provider.EntityByName('MJ: AI Agent Sessions')?.ID;
305
+ if (!sessionEntityID) {
306
+ return;
307
+ }
308
+ const link = await provider.GetEntityObject('MJ: File Entity Record Links', contextUser);
309
+ link.NewRecord();
310
+ link.FileID = fileID;
311
+ link.EntityID = sessionEntityID;
312
+ link.RecordID = sessionID;
313
+ if (!await link.Save()) {
314
+ LogError(`storeRealtimeRecording: failed to link recording to session ${sessionID}: ${link.LatestResult?.CompleteMessage ?? 'unknown error'}`);
315
+ }
316
+ }
317
+ /** Stamps the recording fields on an already-loaded session (file + media kind + t0). Logs and returns false on a failed save. */
318
+ async function stampRecordingOnSession(session, fileID, media, startedAt) {
319
+ session.RecordingFileID = fileID;
320
+ session.RecordingMedia = media;
321
+ session.RecordingStartedAt = startedAt;
322
+ if (!await session.Save()) {
323
+ LogError(`storeRealtimeRecording: failed to stamp recording fields on session ${session.ID}: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
324
+ return false;
236
325
  }
326
+ return true;
237
327
  }
238
328
  /** @deprecated Use {@link StoreRealtimeRecording}. */
239
329
  export async function storeRealtimeRecording(input) {
240
330
  return StoreRealtimeRecording(input);
241
331
  }
332
+ const RECORDING_WAV_HEADER_BYTES = 44;
333
+ const RECORDING_BYTES_PER_SAMPLE = 2; // mono, 16-bit PCM
334
+ /** WAV stores sizes as uint32; the RIFF chunk size is `36 + dataSize`, so data tops out 36 bytes short of the max. */
335
+ const RECORDING_MAX_DATA_BYTES = 0xFFFFFFFF - 36;
336
+ /**
337
+ * Assembles recovered recording shards into one WAV, in index order, filling every absent index with
338
+ * silence. Silence (rather than skipping the gap) keeps all later audio at its true time offset, so it
339
+ * stays aligned with transcript cues, which are offsets from the session's `RecordingStartedAt`.
340
+ *
341
+ * The gap length is the median length of the present shards excluding the highest present index (that
342
+ * one is normally a partial window); with a single shard, that shard's length. It is rounded down to
343
+ * an even byte count (whole 16-bit samples), minimum 2.
344
+ *
345
+ * The byte ceiling does not bound the index range on its own: under the default ceiling a single shard
346
+ * at index ~10^9 still fits, and the missing-index list grows with the highest index. Callers handling
347
+ * untrusted shards must cap the shard count and highest index themselves (recovery does, via
348
+ * {@link MAX_RECOVERY_SEGMENTS}) and pass a smaller `maxDataBytes`.
349
+ *
350
+ * @param segments Recovered shards; any order, unique non-negative integer indexes starting from 0.
351
+ * @param sampleRate Sample rate of the PCM data, in Hz.
352
+ * @param maxDataBytes Ceiling on the PCM data size (shards plus gap fill), checked before anything is
353
+ * allocated. Defaults to the WAV format's own limit; callers handling untrusted shards pass less.
354
+ * @throws Error on no segments, an invalid sample rate or index, a duplicate index, an empty or
355
+ * odd-length shard, or a result too large for a WAV.
356
+ */
357
+ export function BuildRecordingFromSegments(segments, sampleRate, maxDataBytes = RECORDING_MAX_DATA_BYTES) {
358
+ validateRecordingInputs(segments, sampleRate);
359
+ const ordered = [...segments].sort((a, b) => a.Index - b.Index);
360
+ const gapBytes = computeRecordingGapBytes(ordered);
361
+ // Count gaps arithmetically (indexes are unique and non-negative) so an absurd index fails the size
362
+ // check below instead of first enumerating billions of missing indexes.
363
+ const missingCount = ordered[ordered.length - 1].Index - (ordered.length - 1);
364
+ const dataSize = ordered.reduce((sum, s) => sum + s.Bytes.length, 0) + missingCount * gapBytes;
365
+ const limit = Math.min(maxDataBytes, RECORDING_MAX_DATA_BYTES);
366
+ if (dataSize > limit) {
367
+ throw new Error(`BuildRecordingFromSegments: data of ${dataSize} bytes exceeds the maximum WAV data size of ${limit} bytes`);
368
+ }
369
+ // Buffer.alloc zero-fills, so gaps need no explicit write.
370
+ const wav = Buffer.alloc(RECORDING_WAV_HEADER_BYTES + dataSize);
371
+ writeRecordingWavHeader(wav, sampleRate, dataSize);
372
+ let offset = RECORDING_WAV_HEADER_BYTES;
373
+ let expectedIndex = 0;
374
+ for (const segment of ordered) {
375
+ offset += (segment.Index - expectedIndex) * gapBytes;
376
+ offset += segment.Bytes.copy(wav, offset);
377
+ expectedIndex = segment.Index + 1;
378
+ }
379
+ return { Wav: wav, MissingIndexes: findMissingIndexes(ordered), GapBytes: gapBytes };
380
+ }
381
+ function validateRecordingInputs(segments, sampleRate) {
382
+ if (segments.length === 0) {
383
+ throw new Error('BuildRecordingFromSegments: no segments to assemble');
384
+ }
385
+ if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
386
+ throw new Error(`BuildRecordingFromSegments: sample rate must be a positive integer, got ${sampleRate}`);
387
+ }
388
+ const seen = new Set();
389
+ for (const { Index, Bytes } of segments) {
390
+ if (!Number.isInteger(Index) || Index < 0) {
391
+ throw new Error(`BuildRecordingFromSegments: segment index must be a non-negative integer, got ${Index}`);
392
+ }
393
+ if (seen.has(Index)) {
394
+ throw new Error(`BuildRecordingFromSegments: duplicate segment index ${Index}`);
395
+ }
396
+ seen.add(Index);
397
+ if (Bytes.length === 0) {
398
+ throw new Error(`BuildRecordingFromSegments: segment ${Index} is empty`);
399
+ }
400
+ if (Bytes.length % RECORDING_BYTES_PER_SAMPLE !== 0) {
401
+ throw new Error(`BuildRecordingFromSegments: segment ${Index} has odd length ${Bytes.length}; PCM16 needs an even byte count`);
402
+ }
403
+ }
404
+ }
405
+ /** Median length of the present shards minus the highest-indexed one (a partial window), floored to even, min 2. */
406
+ function computeRecordingGapBytes(ordered) {
407
+ const basis = ordered.length > 1 ? ordered.slice(0, -1) : ordered;
408
+ const lengths = basis.map(s => s.Bytes.length).sort((a, b) => a - b);
409
+ const mid = Math.floor(lengths.length / 2);
410
+ const median = lengths.length % 2 === 1 ? lengths[mid] : (lengths[mid - 1] + lengths[mid]) / 2;
411
+ const even = Math.floor(median / RECORDING_BYTES_PER_SAMPLE) * RECORDING_BYTES_PER_SAMPLE;
412
+ return Math.max(RECORDING_BYTES_PER_SAMPLE, even);
413
+ }
414
+ /** Indexes in [0, highest present) with no shard. `ordered` is sorted ascending. */
415
+ function findMissingIndexes(ordered) {
416
+ const highestIndex = ordered[ordered.length - 1].Index;
417
+ const present = new Set(ordered.map(s => s.Index));
418
+ const missing = [];
419
+ for (let i = 0; i < highestIndex; i++) {
420
+ if (!present.has(i)) {
421
+ missing.push(i);
422
+ }
423
+ }
424
+ return missing;
425
+ }
426
+ /** Same canonical header layout as `RealtimeRecordingCapture.encodeWavBuffer` (mono, 16-bit, PCM). */
427
+ function writeRecordingWavHeader(buffer, sampleRate, dataSize) {
428
+ buffer.write('RIFF', 0, 'ascii');
429
+ buffer.writeUInt32LE(36 + dataSize, 4);
430
+ buffer.write('WAVE', 8, 'ascii');
431
+ buffer.write('fmt ', 12, 'ascii');
432
+ buffer.writeUInt32LE(16, 16);
433
+ buffer.writeUInt16LE(1, 20);
434
+ buffer.writeUInt16LE(1, 22);
435
+ buffer.writeUInt32LE(sampleRate, 24);
436
+ buffer.writeUInt32LE(sampleRate * RECORDING_BYTES_PER_SAMPLE, 28);
437
+ buffer.writeUInt16LE(RECORDING_BYTES_PER_SAMPLE, 32);
438
+ buffer.writeUInt16LE(16, 34);
439
+ buffer.write('data', 36, 'ascii');
440
+ buffer.writeUInt32LE(dataSize, 40);
441
+ }
442
+ /**
443
+ * Most shards recovery will assemble. At ~15 s per shard this is an hour of audio, far past any real
444
+ * call; a listing at or beyond it (by count or by highest index) is treated as corrupt or hostile and
445
+ * fails closed rather than allocating a huge WAV.
446
+ */
447
+ export const MAX_RECOVERY_SEGMENTS = 240;
448
+ /**
449
+ * Ceiling on the PCM data a recovery will assemble: 384 MiB covers an hour of 48 kHz mono PCM16
450
+ * (345.6 MB) with headroom. Shard sizes are client-controlled (the GraphQL body limit is 50 MB, so one
451
+ * shard can be ~37 MB) and gaps are filled with silence, so without this a session owner could make the
452
+ * janitor allocate gigabytes. Checked against listed sizes, running actual bytes, and the assembled size.
453
+ *
454
+ * It caps the data, not the process's memory: the shard buffers are still held while the WAV is
455
+ * assembled (2x), and storage drivers can copy on read (the S3 driver copies each shard once more), so
456
+ * peak memory for one recovery is up to about three times this: two 288 MB recoveries raised the API
457
+ * process's RSS by 730 MB and 920 MB.
458
+ */
459
+ export const MAX_RECOVERY_BYTES = 384 * 1024 * 1024;
460
+ /**
461
+ * Seconds of audio in one full shard written before shard keys carried a rate. Those clients flushed a
462
+ * shard every 15 s. The legacy format is frozen, so this deliberately does not follow the client's
463
+ * current flush interval: it is only used to infer the rate of shards that declare none.
464
+ */
465
+ const LEGACY_SHARD_SECONDS = 15;
466
+ /** Sample rates a legacy shard's rate may be inferred as. */
467
+ const STANDARD_PCM_RATES = [8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000];
468
+ /**
469
+ * How far a full legacy shard's size-implied rate may be from a standard rate (relative). Real full
470
+ * shards are within ~0.2% of 15 s; the closest pair of standard rates is ~9% apart.
471
+ */
472
+ const RATE_SNAP_TOLERANCE = 0.02;
473
+ /** `seg-0003.pcm` or `seg-0003.r48000.pcm`: index, optional keyed rate, extension. */
474
+ const SEGMENT_KEY_PATTERN = /^seg-(\d+)(?:\.r(\d+))?\.([A-Za-z0-9]+)$/;
475
+ /**
476
+ * A fresh name per recovery attempt (`recording-recovered-<epoch ms>-<random hex>.wav`). Storage keys are
477
+ * `<folder>/<file name>`, so a fixed name would make retries and concurrent instances write the SAME
478
+ * object behind several `MJ: Files` rows: deleting an orphan row would delete the stamped recording, and
479
+ * a late attempt could overwrite it with a mostly silent WAV.
480
+ */
481
+ function recoveredFileName() {
482
+ return `recording-recovered-${Date.now()}-${randomBytes(4).toString('hex')}.wav`;
483
+ }
484
+ /**
485
+ * Rebuilds a session's recording from the `seg-*` crash-recovery shards left in its storage folder —
486
+ * for a call whose browser died or whose end-of-call upload never ran, so no `recording.*` exists.
487
+ * Shards are concatenated in order (silence for absent ones), uploaded under a per-attempt
488
+ * `recording-recovered-<epoch ms>-<random hex>.wav`, stamped on the session, linked, and only then
489
+ * deleted. Deletion happens only after a reload confirms the session points at the recovered file, so a
490
+ * failure at any step leaves the shards for the next try. If the session has (or gains) another
491
+ * recording, the outcome is `Superseded` and the shards are kept. Deferring to that recording is best
492
+ * effort: the session is re-checked immediately before the stamp, but a recording stamped inside that
493
+ * one load -> save window is overwritten (there is no compare-and-set).
494
+ *
495
+ * The sample rate comes from the shard keys (`seg-NNNN.r<rate>.pcm`) or, for legacy `seg-NNNN.pcm`
496
+ * shards, from each object's stored content type. When no shard declares a rate (a legacy shard on a
497
+ * driver that keeps no content type, such as Box), it is inferred from the size of the full shards; see
498
+ * {@link LEGACY_SHARD_SECONDS}.
499
+ *
500
+ * Once the stamp has saved, the outcome is `Recovered` even if the confirming reload or a shard delete
501
+ * fails; the shards it could not remove are named in a `LogError` (nothing revisits a stamped session).
502
+ *
503
+ * Never throws: every failure is logged with the session id and returned as `Failed`.
504
+ *
505
+ * @param input Session, storage account, recording `t0`, and server context.
506
+ */
507
+ export async function RecoverRealtimeRecordingFromSegments(input) {
508
+ try {
509
+ return await recoverFromSegments(input);
510
+ }
511
+ catch (error) {
512
+ const message = error instanceof Error ? error.message : String(error);
513
+ LogError(`RecoverRealtimeRecordingFromSegments failed (session ${input.SessionID}): ${message}`);
514
+ return recoveryResult('Failed', { ErrorMessage: message });
515
+ }
516
+ }
517
+ function recoveryResult(outcome, fields = {}) {
518
+ return { Outcome: outcome, FileID: null, SegmentCount: 0, MissingIndexes: [], ErrorMessage: null, ...fields };
519
+ }
520
+ async function recoverFromSegments(input) {
521
+ const { SessionID, StorageAccountID, StartedAt, ContextUser, Provider } = input;
522
+ const driver = await FileStorageEngine.Instance.GetDriver(StorageAccountID, ContextUser);
523
+ const shards = await listRecordingShards(driver, SessionID);
524
+ if (shards.length === 0) {
525
+ LogStatus(`RecoverRealtimeRecordingFromSegments: no shards for session ${SessionID} in storage account ${StorageAccountID}`);
526
+ return recoveryResult('NoSegments');
527
+ }
528
+ assertRecoverable(shards);
529
+ const declaredRate = await resolveDeclaredSampleRate(driver, shards);
530
+ const segments = await readShardBytes(driver, shards);
531
+ const sampleRate = declaredRate ?? inferSampleRateFromShardSizes(segments, shards);
532
+ if (declaredRate === null) {
533
+ LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): no shard declares a sample rate; inferred ${sampleRate} Hz from the size of its full shards`);
534
+ }
535
+ const assembled = BuildRecordingFromSegments(segments, sampleRate, MAX_RECOVERY_BYTES);
536
+ const description = describeRecovery(shards.length, assembled, sampleRate);
537
+ if (assembled.MissingIndexes.length > 0) {
538
+ LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): ${description}`);
539
+ }
540
+ const stored = await StoreRealtimeRecording({
541
+ Audio: assembled.Wav, MimeType: 'audio/wav', Media: 'Audio', StartedAt, StorageAccountID, SessionID,
542
+ ContextUser, Provider, FileName: recoveredFileName(), Description: description, PreserveExistingRecording: true,
543
+ });
544
+ const found = { FileID: stored.FileID, SegmentCount: shards.length, MissingIndexes: assembled.MissingIndexes };
545
+ if (stored.Superseded) {
546
+ return recoveryResult('Superseded', found);
547
+ }
548
+ // An ErrorMessage with a FileID is an uploaded-but-unstamped orphan (re-read or stamp failed).
549
+ if (!stored.FileID || stored.ErrorMessage) {
550
+ throw new Error(stored.ErrorMessage ?? 'storing the recovered recording failed');
551
+ }
552
+ return finishRecovery(input, stored.FileID, found, shards);
553
+ }
554
+ /** Confirms the stamp stuck, then (and only then) deletes the shards. */
555
+ async function finishRecovery(input, fileID, found, shards) {
556
+ const { SessionID, StorageAccountID, ContextUser, Provider } = input;
557
+ const session = await Provider.GetEntityObject('MJ: AI Agent Sessions', ContextUser);
558
+ if (!await session.Load(SessionID)) {
559
+ // The stamp and link already saved, so the recording is in place; only the overwrite check could
560
+ // not run. Keep the shards (they might belong to a recording that replaced ours) and name them.
561
+ LogError(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): recovered file ${fileID} is stamped, but the session ` +
562
+ `could not be reloaded to confirm it before deleting shards; kept for manual cleanup: ${shards.map(s => s.Path).join(', ')}`);
563
+ return recoveryResult('Recovered', found);
564
+ }
565
+ if (session.RecordingFileID && session.RecordingFileID !== fileID) {
566
+ LogStatus(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): session now points at ${session.RecordingFileID}, not recovered file ${fileID}; keeping shards`);
567
+ return recoveryResult('Superseded', found);
568
+ }
569
+ if (session.RecordingFileID !== fileID) {
570
+ throw new Error(`recovered file ${fileID} uploaded but not stamped on session ${SessionID}; shards kept`);
571
+ }
572
+ const notDeleted = await deleteListedShards(StorageAccountID, ContextUser, shards);
573
+ if (notDeleted.length > 0) {
574
+ LogError(`RecoverRealtimeRecordingFromSegments (session ${SessionID}): deleted ${shards.length - notDeleted.length} of ${shards.length} ` +
575
+ `shards after recovery; left for manual cleanup: ${notDeleted.join(', ')}`);
576
+ }
577
+ return recoveryResult('Recovered', found);
578
+ }
579
+ /**
580
+ * Deletes exactly the shards that were assembled. A re-list would also take shards written after our
581
+ * listing, whose audio is not in the recovered file. Returns the paths it could not delete.
582
+ */
583
+ async function deleteListedShards(storageAccountID, contextUser, shards) {
584
+ const driver = await FileStorageEngine.Instance.GetDriver(storageAccountID, contextUser);
585
+ const notDeleted = [];
586
+ for (const shard of shards) {
587
+ if (!await driver.DeleteObject(shard.Path)) {
588
+ notDeleted.push(shard.Path);
589
+ }
590
+ }
591
+ return notDeleted;
592
+ }
593
+ /** Lists the session folder's `seg-*` objects. Other objects (recording.*, peaks.json) are ignored. */
594
+ async function listRecordingShards(driver, sessionID) {
595
+ const folder = recordingFolder(sessionID);
596
+ const listed = await driver.ListObjects(`${folder}/`);
597
+ const shards = [];
598
+ for (const obj of listed.objects ?? []) {
599
+ const name = obj.name.split('/').pop() ?? obj.name;
600
+ const match = SEGMENT_KEY_PATTERN.exec(name);
601
+ if (match) {
602
+ const keyedRate = match[2] === undefined ? null : Number(match[2]);
603
+ if (keyedRate !== null && !(Number.isSafeInteger(keyedRate) && keyedRate > 0)) {
604
+ throw new Error(`shard ${name} has an invalid sample rate in its key`);
605
+ }
606
+ shards.push({
607
+ Name: name, Path: `${folder}/${name}`, Index: Number(match[1]),
608
+ KeyedRate: keyedRate, Extension: match[3], ListedSize: obj.size ?? 0,
609
+ });
610
+ }
611
+ }
612
+ return shards;
613
+ }
614
+ /** Fail closed on an oversized listing and on anything that is not raw PCM (webm/ogg shards cannot be concatenated). */
615
+ function assertRecoverable(shards) {
616
+ const highestIndex = Math.max(...shards.map(s => s.Index));
617
+ if (shards.length >= MAX_RECOVERY_SEGMENTS || highestIndex >= MAX_RECOVERY_SEGMENTS) {
618
+ throw new Error(`refusing to recover ${shards.length} shards (highest index ${highestIndex}); limit is ${MAX_RECOVERY_SEGMENTS}`);
619
+ }
620
+ const listedBytes = shards.reduce((sum, s) => sum + s.ListedSize, 0);
621
+ if (listedBytes > MAX_RECOVERY_BYTES) {
622
+ throw new Error(`shards total ${listedBytes} bytes, over the recovery limit of ${MAX_RECOVERY_BYTES} bytes`);
623
+ }
624
+ const notPcm = shards.find(s => s.Extension !== 'pcm');
625
+ if (notPcm) {
626
+ throw new Error(`shard ${notPcm.Name} is not raw PCM; only .pcm shards can be reassembled`);
627
+ }
628
+ }
629
+ /**
630
+ * The one sample rate the shards declare: from the key when present, else the object's stored content
631
+ * type. Null when no shard declares one (left to {@link inferSampleRateFromShardSizes}). Throws when
632
+ * shards disagree, or when some declare a rate and others do not.
633
+ */
634
+ async function resolveDeclaredSampleRate(driver, shards) {
635
+ const declared = [];
636
+ for (const shard of shards) {
637
+ const rate = shard.KeyedRate ?? parsePcmRate((await driver.GetObjectMetadata({ fullPath: shard.Path })).contentType ?? '');
638
+ declared.push({ shard, rate });
639
+ }
640
+ const withRate = declared.filter(d => d.rate !== null);
641
+ if (withRate.length === 0) {
642
+ return null;
643
+ }
644
+ const undeclared = declared.find(d => d.rate === null);
645
+ if (undeclared) {
646
+ throw new Error(`cannot determine the sample rate of shard ${undeclared.shard.Name}`);
647
+ }
648
+ const first = withRate[0];
649
+ const disagreeing = withRate.find(d => d.rate !== first.rate);
650
+ if (disagreeing) {
651
+ throw new Error(`shards disagree on sample rate: ${first.shard.Name} is ${first.rate} Hz but ${disagreeing.shard.Name} is ${disagreeing.rate} Hz`);
652
+ }
653
+ return first.rate;
654
+ }
655
+ /**
656
+ * Infers the sample rate of shards that declare none from the size of their full shards: every shard
657
+ * but the highest-indexed (normally a partial window) holds {@link LEGACY_SHARD_SECONDS} of mono PCM16.
658
+ * Each full shard's implied rate must be within {@link RATE_SNAP_TOLERANCE} of a standard rate, and all
659
+ * must agree. A lone shard is refused, since it may be a partial window.
660
+ */
661
+ function inferSampleRateFromShardSizes(segments, shards) {
662
+ const nameOf = (index) => shards.find(s => s.Index === index)?.Name ?? `index ${index}`;
663
+ const ordered = [...segments].sort((a, b) => a.Index - b.Index);
664
+ const full = ordered.slice(0, -1);
665
+ if (full.length === 0) {
666
+ throw new Error(`cannot determine the sample rate of shard ${nameOf(ordered[0].Index)}: neither its key nor its content type carries one, ` +
667
+ `and a lone shard may be a partial window, so its size cannot be trusted`);
668
+ }
669
+ const rates = full.map(segment => {
670
+ const implied = segment.Bytes.length / RECORDING_BYTES_PER_SAMPLE / LEGACY_SHARD_SECONDS;
671
+ const snapped = STANDARD_PCM_RATES.find(r => Math.abs(implied - r) / r <= RATE_SNAP_TOLERANCE);
672
+ if (snapped === undefined) {
673
+ throw new Error(`cannot determine the sample rate of shard ${nameOf(segment.Index)}: its size (${segment.Bytes.length} bytes) ` +
674
+ `implies ${Math.round(implied)} Hz, not a standard rate`);
675
+ }
676
+ return snapped;
677
+ });
678
+ const disagreeing = rates.findIndex(r => r !== rates[0]);
679
+ if (disagreeing !== -1) {
680
+ throw new Error(`shards disagree on sample rate: ${nameOf(full[0].Index)}'s size implies ${rates[0]} Hz ` +
681
+ `but ${nameOf(full[disagreeing].Index)}'s implies ${rates[disagreeing]} Hz`);
682
+ }
683
+ return rates[0];
684
+ }
685
+ /** Sequential on purpose: bounds memory and request concurrency against the storage account. */
686
+ async function readShardBytes(driver, shards) {
687
+ const segments = [];
688
+ let total = 0;
689
+ for (const shard of shards) {
690
+ const bytes = await driver.GetObject({ fullPath: shard.Path });
691
+ total += bytes.length;
692
+ // Listed sizes can be 0 or wrong, so the real total is enforced as bytes arrive.
693
+ if (total > MAX_RECOVERY_BYTES) {
694
+ throw new Error(`shards read so far total ${total} bytes (at ${shard.Name}), over the recovery limit of ${MAX_RECOVERY_BYTES} bytes`);
695
+ }
696
+ segments.push({ Index: shard.Index, Bytes: bytes });
697
+ }
698
+ return segments;
699
+ }
700
+ function describeRecovery(shardCount, assembled, sampleRate) {
701
+ const head = `Recovered from ${shardCount} crash-recovery segment(s)`;
702
+ if (assembled.MissingIndexes.length === 0) {
703
+ return `${head}.`;
704
+ }
705
+ const seconds = assembled.GapBytes / RECORDING_BYTES_PER_SAMPLE / sampleRate;
706
+ return `${head}; missing segment(s) ${assembled.MissingIndexes.join(', ')} filled with estimated silence (~${seconds.toFixed(1)} s each).`;
707
+ }
242
708
  //# sourceMappingURL=realtime-recording-store.js.map