@voxevolv/client 0.3.2 → 0.3.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.
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
2
2
  import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
3
3
  import { ReplacementContextQueue } from "./context-queue.js";
4
+ import { ClientDiagnosticBuffer, } from "./diagnostics.js";
5
+ import { TranscriptStore } from "./transcripts.js";
6
+ export { TranscriptStore } from "./transcripts.js";
4
7
  export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
5
8
  export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
6
- export const SDK_VERSION = "0.3.2";
9
+ export const SDK_VERSION = "0.3.4";
7
10
  const CLIENT_HELLO_TIMEOUT_MS = 30_000;
11
+ const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
8
12
  export class RealtimeSessionHttpError extends Error {
9
13
  status;
10
14
  code;
@@ -35,6 +39,8 @@ export class RealtimeSession {
35
39
  readyListeners = new Set();
36
40
  userTranscriptListeners = new Set();
37
41
  agentTranscriptListeners = new Set();
42
+ transcriptListeners = new Set();
43
+ transcriptStore = new TranscriptStore();
38
44
  errorListeners = new Set();
39
45
  endedListeners = new Set();
40
46
  toolCallListeners = new Set();
@@ -74,6 +80,8 @@ export class RealtimeSession {
74
80
  refreshCredentialsCallback;
75
81
  autoRejoinOptions;
76
82
  lifecycleCredentials;
83
+ diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
84
+ diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
77
85
  constructor(credentials, options = {}) {
78
86
  validateSessionCredentials(credentials);
79
87
  this.id = credentials.id;
@@ -248,6 +256,14 @@ export class RealtimeSession {
248
256
  this.agentTranscriptListeners.add(listener);
249
257
  return () => this.agentTranscriptListeners.delete(listener);
250
258
  }
259
+ /** Latest message snapshots, retained across transport recovery and replay. */
260
+ get transcript() {
261
+ return this.transcriptStore.snapshot;
262
+ }
263
+ onTranscriptUpdated(listener) {
264
+ this.transcriptListeners.add(listener);
265
+ return () => this.transcriptListeners.delete(listener);
266
+ }
251
267
  onError(listener) {
252
268
  this.errorListeners.add(listener);
253
269
  return () => this.errorListeners.delete(listener);
@@ -372,7 +388,7 @@ export class RealtimeSession {
372
388
  const normalizedApiBase = normalizeApiBase(apiBase);
373
389
  const recovering = this.hasConnectedValue;
374
390
  if (recovering) {
375
- this.beginRecovery("rejoin", 1);
391
+ this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
376
392
  this.setState("resuming");
377
393
  }
378
394
  try {
@@ -445,18 +461,73 @@ export class RealtimeSession {
445
461
  }
446
462
  async sendContextUpdate(update, timeoutMs) {
447
463
  const generation = this.contextGeneration;
464
+ const payloadBytes = contextUpdateBytes(update);
465
+ const transferStartedAt = Date.now();
466
+ let transferTimedOut = false;
467
+ this.recordDiagnostic({
468
+ operation: "context_transfer",
469
+ status: "started",
470
+ context_key: update.key,
471
+ payload_bytes: payloadBytes,
472
+ });
448
473
  let transferTimeout;
449
474
  const transferExpired = new Promise((_resolve, reject) => {
450
475
  transferTimeout = globalThis.setTimeout(() => {
476
+ transferTimedOut = true;
451
477
  const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
452
- this.startAutoRejoin(error);
478
+ this.recordDiagnostic({
479
+ operation: "context_transfer",
480
+ status: "timed_out",
481
+ reason: "context_transfer_timeout",
482
+ context_key: update.key,
483
+ payload_bytes: payloadBytes,
484
+ duration_ms: Date.now() - transferStartedAt,
485
+ timeout_ms: timeoutMs,
486
+ });
487
+ this.flushDiagnostics("context_transfer_timeout");
488
+ this.startAutoRejoin(error, {
489
+ trigger: "context_transfer_timeout",
490
+ operation: "context_transfer",
491
+ contextKey: update.key,
492
+ timeoutMs,
493
+ });
453
494
  reject(error);
454
495
  }, timeoutMs);
455
496
  });
456
- const transfer = await Promise.race([
457
- "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
458
- transferExpired,
459
- ]).finally(() => globalThis.clearTimeout(transferTimeout));
497
+ let transfer;
498
+ try {
499
+ transfer = await Promise.race([
500
+ "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
501
+ transferExpired,
502
+ ]);
503
+ }
504
+ catch (error) {
505
+ if (!transferTimedOut) {
506
+ this.recordDiagnostic({
507
+ operation: "context_transfer",
508
+ status: "failed",
509
+ reason: "context_transfer_failed",
510
+ context_key: update.key,
511
+ payload_bytes: payloadBytes,
512
+ duration_ms: Date.now() - transferStartedAt,
513
+ });
514
+ this.flushDiagnostics("command_failed");
515
+ }
516
+ throw error;
517
+ }
518
+ finally {
519
+ globalThis.clearTimeout(transferTimeout);
520
+ }
521
+ const transferDurationMs = Date.now() - transferStartedAt;
522
+ this.recordDiagnostic({
523
+ operation: "context_transfer",
524
+ status: "succeeded",
525
+ context_key: update.key,
526
+ payload_bytes: payloadBytes,
527
+ duration_ms: transferDurationMs,
528
+ });
529
+ if (transferDurationMs > 2_000)
530
+ this.flushDiagnostics("slow_operation");
460
531
  if (generation !== this.contextGeneration)
461
532
  throw new Error("Context transfer interrupted by recovery");
462
533
  const { payload } = transfer;
@@ -611,7 +682,7 @@ export class RealtimeSession {
611
682
  bindRoomEvents() {
612
683
  this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
613
684
  if (participant.isAgent && !this.disconnectMode) {
614
- this.startAutoRejoin(new Error("Agent disconnected from the room"));
685
+ this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
615
686
  }
616
687
  });
617
688
  this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
@@ -720,7 +791,7 @@ export class RealtimeSession {
720
791
  this.reconnectStartedAt ??= Date.now();
721
792
  this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
722
793
  if (this.activeRecovery === null) {
723
- this.beginRecovery("livekit", 1);
794
+ this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
724
795
  }
725
796
  this.setState("reconnecting");
726
797
  }
@@ -738,7 +809,7 @@ export class RealtimeSession {
738
809
  this.reconnectStartedAt = null;
739
810
  }
740
811
  if (this.hasConnectedValue && this.activeRecovery === null) {
741
- this.beginRecovery("livekit", 1);
812
+ this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
742
813
  }
743
814
  this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
744
815
  this.clientSeq = 0;
@@ -762,7 +833,7 @@ export class RealtimeSession {
762
833
  return;
763
834
  const normalizedError = toError(error);
764
835
  if (this.shouldAutoRejoin()) {
765
- this.startAutoRejoin(normalizedError);
836
+ this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
766
837
  }
767
838
  else {
768
839
  this.failRecovery(normalizedError);
@@ -792,7 +863,7 @@ export class RealtimeSession {
792
863
  return;
793
864
  }
794
865
  if (this.shouldAutoRejoin(reason)) {
795
- this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
866
+ this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
796
867
  return;
797
868
  }
798
869
  if (this.activeRecovery !== null) {
@@ -816,12 +887,22 @@ export class RealtimeSession {
816
887
  this.endNotificationDelivered = true;
817
888
  this.notifyListeners(this.endedListeners, event);
818
889
  }
819
- beginRecovery(strategy, attempt) {
820
- this.activeRecovery = { strategy, attempt };
890
+ beginRecovery(strategy, attempt, details) {
891
+ this.activeRecovery = { strategy, attempt, details };
892
+ this.recordDiagnostic({
893
+ operation: "connection_recovery",
894
+ status: "started",
895
+ reason: details.trigger,
896
+ message_id: details.messageId,
897
+ context_key: details.contextKey,
898
+ timeout_ms: details.timeoutMs,
899
+ });
900
+ this.flushDiagnostics("recovery_started");
821
901
  this.notifyListeners(this.recoveryListeners, {
822
902
  status: "started",
823
903
  strategy,
824
904
  attempt,
905
+ ...details,
825
906
  });
826
907
  }
827
908
  completeRecovery() {
@@ -833,6 +914,7 @@ export class RealtimeSession {
833
914
  status: "succeeded",
834
915
  strategy: recovery.strategy,
835
916
  attempt: recovery.attempt,
917
+ ...recovery.details,
836
918
  });
837
919
  }
838
920
  failRecovery(error) {
@@ -844,6 +926,7 @@ export class RealtimeSession {
844
926
  status: "failed",
845
927
  strategy: recovery.strategy,
846
928
  attempt: recovery.attempt,
929
+ ...recovery.details,
847
930
  error,
848
931
  });
849
932
  }
@@ -854,7 +937,7 @@ export class RealtimeSession {
854
937
  && !["disconnected", "failed"].includes(this.stateValue)
855
938
  && !isTerminalDisconnectReason(reason));
856
939
  }
857
- startAutoRejoin(initialError) {
940
+ startAutoRejoin(initialError, details) {
858
941
  if (this.autoRejoinPromise || !this.shouldAutoRejoin())
859
942
  return;
860
943
  this.agentReady = false;
@@ -875,7 +958,7 @@ export class RealtimeSession {
875
958
  this.failRecovery(error);
876
959
  this.setState("failed");
877
960
  }, this.autoRejoinOptions.timeoutMs);
878
- const promise = this.runAutoRejoin(initialError, controller.signal)
961
+ const promise = this.runAutoRejoin(initialError, controller.signal, details)
879
962
  .finally(() => {
880
963
  globalThis.clearTimeout(timeout);
881
964
  if (this.autoRejoinController === controller) {
@@ -886,7 +969,7 @@ export class RealtimeSession {
886
969
  this.autoRejoinPromise = promise;
887
970
  void promise;
888
971
  }
889
- async runAutoRejoin(initialError, signal) {
972
+ async runAutoRejoin(initialError, signal, details) {
890
973
  const options = this.autoRejoinOptions;
891
974
  if (!options)
892
975
  return;
@@ -904,7 +987,7 @@ export class RealtimeSession {
904
987
  await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
905
988
  }
906
989
  throwIfAborted(signal);
907
- this.beginRecovery("rejoin", attempt);
990
+ this.beginRecovery("rejoin", attempt, details);
908
991
  await this.performRejoin(this.apiBase, signal);
909
992
  throwIfAborted(signal);
910
993
  this.reconnectStartedAt = null;
@@ -1023,6 +1106,9 @@ export class RealtimeSession {
1023
1106
  }
1024
1107
  if (event.type.endsWith(".error"))
1025
1108
  this.notifyListeners(this.errorListeners, event);
1109
+ const message = this.transcriptStore.accept(event);
1110
+ if (message)
1111
+ this.notifyListeners(this.transcriptListeners, message);
1026
1112
  const transcript = transcriptEvent(event);
1027
1113
  if (!transcript)
1028
1114
  return;
@@ -1055,6 +1141,21 @@ export class RealtimeSession {
1055
1141
  this.pendingReconnect = null;
1056
1142
  }
1057
1143
  async sendTrackedCommand(message, timeoutMs, retryTransfer) {
1144
+ const contextKey = contextKeyFromMessage(message);
1145
+ const payloadBytes = contextPayloadBytes(message.payload);
1146
+ const startedAt = Date.now();
1147
+ this.recordDiagnostic({
1148
+ operation: "command_send",
1149
+ status: "started",
1150
+ message_id: message.message_id,
1151
+ connection_id: message.connection_id,
1152
+ client_seq: message.client_seq,
1153
+ context_key: contextKey,
1154
+ payload_bytes: payloadBytes,
1155
+ retry_count: 0,
1156
+ timeout_ms: timeoutMs,
1157
+ event_type: diagnosticEventType(message.type),
1158
+ });
1058
1159
  const response = new Promise((resolve, reject) => {
1059
1160
  const timeout = globalThis.setTimeout(() => {
1060
1161
  const pending = this.pendingCommands.get(message.message_id);
@@ -1062,17 +1163,46 @@ export class RealtimeSession {
1062
1163
  globalThis.clearInterval(pending.retry);
1063
1164
  this.pendingCommands.delete(message.message_id);
1064
1165
  const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
1166
+ this.recordDiagnostic({
1167
+ operation: "command_send",
1168
+ status: "timed_out",
1169
+ reason: message.type === "context.update"
1170
+ ? "context_command_timeout"
1171
+ : "command_timeout",
1172
+ message_id: message.message_id,
1173
+ connection_id: message.connection_id,
1174
+ client_seq: message.client_seq,
1175
+ context_key: contextKey,
1176
+ payload_bytes: payloadBytes,
1177
+ duration_ms: Date.now() - startedAt,
1178
+ retry_count: pending?.retryCount ?? 0,
1179
+ timeout_ms: timeoutMs,
1180
+ event_type: diagnosticEventType(message.type),
1181
+ });
1182
+ this.flushDiagnostics("command_timeout");
1065
1183
  reject(error);
1066
- if (message.type === "context.update")
1067
- this.startAutoRejoin(error);
1184
+ if (message.type === "context.update") {
1185
+ this.startAutoRejoin(error, {
1186
+ trigger: "context_command_timeout",
1187
+ operation: "command_send",
1188
+ messageId: message.message_id,
1189
+ contextKey: contextKeyFromMessage(message),
1190
+ timeoutMs,
1191
+ });
1192
+ }
1068
1193
  }, timeoutMs);
1069
1194
  const retry = globalThis.setInterval(() => {
1070
- void this.publishCommand(message).catch(() => {
1195
+ void this.publishCommandAttempt(message, true).catch(() => {
1071
1196
  // A reconnect may temporarily reject writes; retry until timeout.
1072
1197
  });
1073
1198
  }, 500);
1074
1199
  this.pendingCommands.set(message.message_id, {
1075
1200
  commandType: message.type,
1201
+ message,
1202
+ startedAt,
1203
+ contextKey,
1204
+ payloadBytes,
1205
+ retryCount: 0,
1076
1206
  resolve,
1077
1207
  reject,
1078
1208
  timeout,
@@ -1080,14 +1210,14 @@ export class RealtimeSession {
1080
1210
  retryTransfer: retryTransfer
1081
1211
  ? async () => {
1082
1212
  await retryTransfer();
1083
- await this.publishCommand(message);
1213
+ await this.publishCommandAttempt(message, true);
1084
1214
  }
1085
1215
  : undefined,
1086
1216
  transferRetrying: false,
1087
1217
  });
1088
1218
  });
1089
1219
  try {
1090
- await this.publishCommand(message);
1220
+ await this.publishCommandAttempt(message, false);
1091
1221
  }
1092
1222
  catch {
1093
1223
  // The reliable data channel may still be warming immediately after a
@@ -1096,9 +1226,114 @@ export class RealtimeSession {
1096
1226
  }
1097
1227
  return response;
1098
1228
  }
1229
+ async publishCommandAttempt(message, retrying) {
1230
+ const pending = this.pendingCommands.get(message.message_id);
1231
+ if (!pending)
1232
+ return;
1233
+ if (retrying) {
1234
+ pending.retryCount += 1;
1235
+ this.recordDiagnostic({
1236
+ operation: "command_send",
1237
+ status: "retrying",
1238
+ message_id: message.message_id,
1239
+ connection_id: message.connection_id,
1240
+ client_seq: message.client_seq,
1241
+ context_key: pending.contextKey,
1242
+ payload_bytes: pending.payloadBytes,
1243
+ retry_count: pending.retryCount,
1244
+ event_type: diagnosticEventType(message.type),
1245
+ });
1246
+ }
1247
+ const attemptStartedAt = Date.now();
1248
+ try {
1249
+ await this.publishCommand(message);
1250
+ }
1251
+ catch (error) {
1252
+ this.recordDiagnostic({
1253
+ operation: "command_send",
1254
+ status: "failed",
1255
+ reason: "command_publish_failed",
1256
+ message_id: message.message_id,
1257
+ connection_id: message.connection_id,
1258
+ client_seq: message.client_seq,
1259
+ context_key: pending.contextKey,
1260
+ payload_bytes: pending.payloadBytes,
1261
+ duration_ms: Date.now() - attemptStartedAt,
1262
+ retry_count: pending.retryCount,
1263
+ event_type: diagnosticEventType(message.type),
1264
+ });
1265
+ this.flushDiagnostics("command_failed");
1266
+ throw error;
1267
+ }
1268
+ const durationMs = Date.now() - attemptStartedAt;
1269
+ this.recordDiagnostic({
1270
+ operation: "command_send",
1271
+ status: "succeeded",
1272
+ message_id: message.message_id,
1273
+ connection_id: message.connection_id,
1274
+ client_seq: message.client_seq,
1275
+ context_key: pending.contextKey,
1276
+ payload_bytes: pending.payloadBytes,
1277
+ duration_ms: durationMs,
1278
+ retry_count: pending.retryCount,
1279
+ event_type: diagnosticEventType(message.type),
1280
+ });
1281
+ if (durationMs > 2_000)
1282
+ this.flushDiagnostics("slow_operation");
1283
+ }
1099
1284
  async publishCommand(message) {
1100
1285
  await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
1101
1286
  }
1287
+ recordDiagnostic(entry) {
1288
+ this.diagnostics.record({
1289
+ ...entry,
1290
+ connection_state: this.stateValue,
1291
+ buffered_bytes: this.reliableDataChannelBufferedBytes(),
1292
+ });
1293
+ }
1294
+ reliableDataChannelBufferedBytes() {
1295
+ try {
1296
+ const bufferedAmount = this.room.engine.dataChannelForKind(0)?.bufferedAmount;
1297
+ return typeof bufferedAmount === "number" ? bufferedAmount : undefined;
1298
+ }
1299
+ catch {
1300
+ return undefined;
1301
+ }
1302
+ }
1303
+ flushDiagnostics(trigger) {
1304
+ void this.diagnostics.flush(trigger, (report) => this.postDiagnostics(report));
1305
+ }
1306
+ async postDiagnostics(report) {
1307
+ const controller = new AbortController();
1308
+ let timeout;
1309
+ const timeoutExpired = new Promise((_resolve, reject) => {
1310
+ timeout = globalThis.setTimeout(() => {
1311
+ controller.abort();
1312
+ reject(new Error(`Diagnostic report timed out after ${this.diagnosticReportTimeoutMs}ms`));
1313
+ }, this.diagnosticReportTimeoutMs);
1314
+ });
1315
+ try {
1316
+ const response = await Promise.race([
1317
+ this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1318
+ method: "POST",
1319
+ headers: {
1320
+ Authorization: `Bearer ${this.accessToken}`,
1321
+ "Content-Type": "application/json",
1322
+ },
1323
+ credentials: this.lifecycleCredentials,
1324
+ body: JSON.stringify(report),
1325
+ signal: controller.signal,
1326
+ }),
1327
+ timeoutExpired,
1328
+ ]);
1329
+ if (!response.ok)
1330
+ throw new Error(`Diagnostic report failed (${response.status})`);
1331
+ }
1332
+ finally {
1333
+ if (timeout !== undefined)
1334
+ globalThis.clearTimeout(timeout);
1335
+ }
1336
+ }
1102
1337
  waitForRemoteAudioTrack(deadline, signal) {
1103
1338
  if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
1104
1339
  return Promise.resolve();
@@ -1162,7 +1397,7 @@ export class RealtimeSession {
1162
1397
  if (event.type !== "agent.ready" && !snapshotReady)
1163
1398
  return;
1164
1399
  if (this.agentStreamId && this.agentStreamId !== event.stream_id && !this.activeRecovery) {
1165
- this.startAutoRejoin(new Error("Agent worker was replaced"));
1400
+ this.startAutoRejoin(new Error("Agent worker was replaced"), { trigger: "worker_replaced" });
1166
1401
  return;
1167
1402
  }
1168
1403
  this.agentStreamId = event.stream_id;
@@ -1184,6 +1419,7 @@ export class RealtimeSession {
1184
1419
  if (!pending)
1185
1420
  return;
1186
1421
  if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
1422
+ this.recordCommandResult(pending, event.type, "acknowledged");
1187
1423
  globalThis.clearTimeout(pending.timeout);
1188
1424
  globalThis.clearInterval(pending.retry);
1189
1425
  this.pendingCommands.delete(event.payload.message_id);
@@ -1191,6 +1427,7 @@ export class RealtimeSession {
1191
1427
  return;
1192
1428
  }
1193
1429
  if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
1430
+ this.recordCommandResult(pending, event.type, "acknowledged");
1194
1431
  globalThis.clearTimeout(pending.timeout);
1195
1432
  globalThis.clearInterval(pending.retry);
1196
1433
  this.pendingCommands.delete(event.payload.message_id);
@@ -1199,6 +1436,8 @@ export class RealtimeSession {
1199
1436
  }
1200
1437
  if ((event.type === "command.error" && isCommandErrorPayload(event.payload))
1201
1438
  || (event.type === "context.rejected" && isContextRejectedPayload(event.payload))) {
1439
+ this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
1440
+ this.flushDiagnostics("command_rejected");
1202
1441
  if (event.type === "context.rejected" && event.payload.retryable) {
1203
1442
  if (pending.retryTransfer && !pending.transferRetrying) {
1204
1443
  pending.transferRetrying = true;
@@ -1220,12 +1459,31 @@ export class RealtimeSession {
1220
1459
  for (const [messageId, pending] of this.pendingCommands) {
1221
1460
  if (preserveContext && pending.commandType === "context.update")
1222
1461
  continue;
1462
+ this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
1223
1463
  globalThis.clearTimeout(pending.timeout);
1224
1464
  globalThis.clearInterval(pending.retry);
1225
1465
  pending.reject(error);
1226
1466
  this.pendingCommands.delete(messageId);
1227
1467
  }
1228
1468
  }
1469
+ recordCommandResult(pending, eventType, status, reason) {
1470
+ const durationMs = Date.now() - pending.startedAt;
1471
+ this.recordDiagnostic({
1472
+ operation: "command_result",
1473
+ status,
1474
+ reason,
1475
+ message_id: pending.message.message_id,
1476
+ connection_id: pending.message.connection_id,
1477
+ client_seq: pending.message.client_seq,
1478
+ context_key: pending.contextKey,
1479
+ payload_bytes: pending.payloadBytes,
1480
+ duration_ms: durationMs,
1481
+ retry_count: pending.retryCount,
1482
+ event_type: eventType,
1483
+ });
1484
+ if (durationMs > 2_000)
1485
+ this.flushDiagnostics("slow_operation");
1486
+ }
1229
1487
  async fetchReplay(apiBase, afterSeq) {
1230
1488
  let nextAfter = afterSeq;
1231
1489
  while (true) {
@@ -1374,6 +1632,43 @@ function contextStreamId() {
1374
1632
  return globalThis.crypto.randomUUID();
1375
1633
  return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
1376
1634
  }
1635
+ function contextUpdateBytes(update) {
1636
+ return "image" in update
1637
+ ? update.image.size
1638
+ : new TextEncoder().encode(update.text).byteLength;
1639
+ }
1640
+ function contextPayloadBytes(payload) {
1641
+ const image = payload.image;
1642
+ if (image && typeof image === "object" && "bytes" in image) {
1643
+ const bytes = image.bytes;
1644
+ if (typeof bytes === "number")
1645
+ return bytes;
1646
+ }
1647
+ const textStream = payload.text_stream;
1648
+ if (textStream && typeof textStream === "object" && "bytes" in textStream) {
1649
+ const bytes = textStream.bytes;
1650
+ if (typeof bytes === "number")
1651
+ return bytes;
1652
+ }
1653
+ if (typeof payload.text === "string") {
1654
+ return new TextEncoder().encode(payload.text).byteLength;
1655
+ }
1656
+ try {
1657
+ return new TextEncoder().encode(JSON.stringify(payload)).byteLength;
1658
+ }
1659
+ catch {
1660
+ return undefined;
1661
+ }
1662
+ }
1663
+ function contextKeyFromMessage(message) {
1664
+ if (message.type !== "context.update")
1665
+ return undefined;
1666
+ const key = message.payload.key;
1667
+ return typeof key === "string" ? key : undefined;
1668
+ }
1669
+ function diagnosticEventType(type) {
1670
+ return type === "client.hello" || type === "context.update" ? type : undefined;
1671
+ }
1377
1672
  function validateSessionCredentials(value) {
1378
1673
  if (!value || typeof value !== "object") {
1379
1674
  throw new TypeError("Session credentials must be an object");