@reconcrap/boss-recommend-mcp 2.0.6 → 2.0.8

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.
@@ -24,6 +24,11 @@ import {
24
24
  } from "../../core/infinite-list/index.js";
25
25
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
26
26
  import { createRunLifecycleManager } from "../../core/run/index.js";
27
+ import {
28
+ addTiming,
29
+ imageEvidenceFilePath,
30
+ measureTiming
31
+ } from "../../core/run/timing.js";
27
32
  import {
28
33
  callScreeningLlm,
29
34
  normalizeText,
@@ -167,6 +172,17 @@ function createFailedLlmResult(error) {
167
172
  };
168
173
  }
169
174
 
175
+ function normalizeScreeningMode(value) {
176
+ const normalized = String(value || "llm").trim().toLowerCase();
177
+ return ["deterministic", "local", "local_scorer"].includes(normalized)
178
+ ? "deterministic"
179
+ : "llm";
180
+ }
181
+
182
+ function createMissingLlmConfigResult() {
183
+ return createFailedLlmResult(new Error("LLM screening config is required for production chat runs"));
184
+ }
185
+
170
186
  function createSkippedDetailResult(cardCandidate, reason, error = null) {
171
187
  return {
172
188
  candidate: cardCandidate,
@@ -334,7 +350,7 @@ export async function runChatWorkflow({
334
350
  maxCandidates = 5,
335
351
  targetPassCount = null,
336
352
  processUntilListEnd = false,
337
- detailLimit = 0,
353
+ detailLimit = null,
338
354
  detailSource = "cascade",
339
355
  closeResume = true,
340
356
  requestResumeForPassed = false,
@@ -353,20 +369,24 @@ export async function runChatWorkflow({
353
369
  llmTimeoutMs = 120000,
354
370
  llmImageLimit = 8,
355
371
  llmImageDetail = "high",
372
+ screeningMode = "llm",
356
373
  listMaxScrolls = 20,
357
374
  listStableSignatureLimit = 2,
358
375
  listWheelDeltaY = 850,
359
376
  listSettleMs = 1200,
360
- listFallbackPoint = null
377
+ listFallbackPoint = null,
378
+ imageOutputDir = ""
361
379
  } = {}, runControl) {
362
380
  if (!client) throw new Error("runChatWorkflow requires a guarded CDP client");
363
381
  const normalizedDetailSource = normalizeDetailSource(detailSource);
382
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
383
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
364
384
  const processedLimit = Math.max(1, Number(maxCandidates) || 1);
365
385
  const passTarget = Number.isFinite(Number(targetPassCount)) && Number(targetPassCount) > 0
366
386
  ? Number(targetPassCount)
367
387
  : null;
368
388
  const normalizedStartFrom = normalizeText(startFrom).toLowerCase() === "unread" ? "unread" : "all";
369
- const detailCountLimit = Math.max(0, Number(detailLimit) || 0);
389
+ const detailCountLimit = detailLimit == null ? processedLimit : Math.max(0, Number(detailLimit) || 0);
370
390
  const networkRecorder = detailCountLimit > 0
371
391
  ? createChatProfileNetworkRecorder(client)
372
392
  : null;
@@ -556,6 +576,7 @@ export async function runChatWorkflow({
556
576
  requested: 0,
557
577
  request_satisfied: 0,
558
578
  request_skipped: 0,
579
+ screening_mode: normalizedScreeningMode,
559
580
  unique_seen: compactInfiniteListState(listState).seen_count,
560
581
  scroll_count: 0,
561
582
  viewport_checks: viewportGuard.getStats().checks,
@@ -569,6 +590,8 @@ export async function runChatWorkflow({
569
590
  || results.filter((item) => item.screening?.passed).length < passTarget
570
591
  )
571
592
  ) {
593
+ const candidateStarted = Date.now();
594
+ const timings = {};
572
595
  await runControl.waitIfPaused();
573
596
  runControl.throwIfCanceled();
574
597
  runControl.setPhase("chat:candidate");
@@ -581,7 +604,7 @@ export async function runChatWorkflow({
581
604
  continue;
582
605
  }
583
606
 
584
- const nextCandidateResult = await getNextInfiniteListCandidate({
607
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
585
608
  client,
586
609
  state: listState,
587
610
  maxScrolls: listMaxScrolls,
@@ -608,7 +631,7 @@ export async function runChatWorkflow({
608
631
  visible_index: visibleIndex
609
632
  }
610
633
  })
611
- });
634
+ }));
612
635
  if (!nextCandidateResult.ok) {
613
636
  const endTopLevelState = await getChatTopLevelState(client);
614
637
  if (!endTopLevelState.is_chat_shell) {
@@ -650,11 +673,11 @@ export async function runChatWorkflow({
650
673
 
651
674
  detailStep = "select_candidate";
652
675
  networkRecorder.clear();
653
- const selected = await selectFreshChatCandidate(client, {
676
+ const selected = await measureTiming(timings, "candidate_click_ms", () => selectFreshChatCandidate(client, {
654
677
  cardNodeId,
655
678
  candidate: cardCandidate,
656
679
  timeoutMs: onlineResumeButtonTimeoutMs
657
- });
680
+ }));
658
681
  if (selected.ready?.forbidden_top_level_navigation) {
659
682
  throw makeForbiddenChatResumeNavigationError(selected.ready.top_level_state);
660
683
  }
@@ -681,13 +704,13 @@ export async function runChatWorkflow({
681
704
  if (!detailResult) {
682
705
  detailStep = "open_online_resume";
683
706
  networkRecorder.clear();
684
- const openedResume = await openChatOnlineResume(client, {
707
+ const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
685
708
  timeoutMs: readyTimeoutMs
686
- });
709
+ }));
687
710
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
688
711
  detailStep = "wait_network";
689
712
  const networkWait = ["network", "cascade"].includes(normalizedDetailSource)
690
- ? await waitForCvNetworkEvents(
713
+ ? await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
691
714
  waitForChatProfileNetworkEvents,
692
715
  networkRecorder,
693
716
  {
@@ -696,8 +719,11 @@ export async function runChatWorkflow({
696
719
  requireLoaded: true,
697
720
  intervalMs: 200
698
721
  }
699
- )
722
+ ))
700
723
  : null;
724
+ if (networkWait?.elapsed_ms != null) {
725
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
726
+ }
701
727
  let contentWait = {
702
728
  ok: false,
703
729
  skipped: false,
@@ -744,10 +770,10 @@ export async function runChatWorkflow({
744
770
 
745
771
  if (!detailResult) {
746
772
  detailStep = "wait_resume_content";
747
- contentWait = await waitForChatResumeContent(client, {
773
+ contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
748
774
  timeoutMs: resumeDomTimeoutMs,
749
775
  intervalMs: 300
750
- });
776
+ }));
751
777
  resumeState = contentWait.resume_state || openedResume.resume_state;
752
778
  resumeHtml = contentWait.resume_html || null;
753
779
  resumeNetworkEvents = networkRecorder.events.slice();
@@ -777,7 +803,13 @@ export async function runChatWorkflow({
777
803
  if (shouldCaptureImage) {
778
804
  if (captureNodeId) {
779
805
  detailStep = "capture_image_fallback";
780
- imageEvidence = await captureScrolledNodeScreenshots(client, captureNodeId, {
806
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
807
+ filePath: imageEvidenceFilePath({
808
+ imageOutputDir,
809
+ domain: "chat",
810
+ runId: runControl?.runId,
811
+ index
812
+ }),
781
813
  padding: 8,
782
814
  maxScreenshots: maxImagePages,
783
815
  wheelDeltaY: imageWheelDeltaY,
@@ -791,7 +823,7 @@ export async function runChatWorkflow({
791
823
  run_candidate_index: index,
792
824
  candidate_key: candidateKey
793
825
  }
794
- });
826
+ }));
795
827
  source = "image";
796
828
  recordCvImageFallback(cvAcquisitionState, {
797
829
  parsedNetworkProfileCount,
@@ -799,21 +831,23 @@ export async function runChatWorkflow({
799
831
  imageEvidence
800
832
  });
801
833
  if (callLlmOnImage) {
802
- if (!llmConfig) throw new Error("callLlmOnImage requires llmConfig");
803
834
  detailStep = "llm_image_screening";
804
- try {
805
- llmResult = await callScreeningLlm({
806
- candidate: detailResult.candidate,
807
- criteria,
808
- config: llmConfig,
809
- timeoutMs: llmTimeoutMs,
810
- imageEvidence,
811
- maxImages: llmImageLimit,
812
- imageDetail: llmImageDetail
813
- });
814
- } catch (error) {
815
- if (!isRecoverableLlmScreeningError(error)) throw error;
816
- llmResult = createFailedLlmResult(error);
835
+ if (!llmConfig) {
836
+ llmResult = createMissingLlmConfigResult();
837
+ } else {
838
+ try {
839
+ llmResult = await measureTiming(timings, "vision_model_ms", () => callScreeningLlm({
840
+ candidate: detailResult.candidate,
841
+ criteria,
842
+ config: llmConfig,
843
+ timeoutMs: llmTimeoutMs,
844
+ imageEvidence,
845
+ maxImages: llmImageLimit,
846
+ imageDetail: llmImageDetail
847
+ }));
848
+ } catch (error) {
849
+ llmResult = createFailedLlmResult(error);
850
+ }
817
851
  }
818
852
  }
819
853
  } else {
@@ -838,28 +872,34 @@ export async function runChatWorkflow({
838
872
  });
839
873
  }
840
874
 
841
- if (llmConfig && !llmResult) {
875
+ if (useLlmScreening && !llmResult) {
842
876
  detailStep = "llm_screening";
843
- try {
844
- llmResult = await callScreeningLlm({
845
- candidate: detailResult.candidate,
846
- criteria,
847
- config: llmConfig,
848
- timeoutMs: llmTimeoutMs,
849
- imageEvidence,
850
- maxImages: llmImageLimit,
851
- imageDetail: llmImageDetail
852
- });
853
- } catch (error) {
854
- if (!isRecoverableLlmScreeningError(error)) throw error;
855
- llmResult = createFailedLlmResult(error);
877
+ if (!llmConfig) {
878
+ llmResult = createMissingLlmConfigResult();
879
+ } else {
880
+ try {
881
+ const llmTimingKey = imageEvidence?.file_paths?.length
882
+ ? "vision_model_ms"
883
+ : "text_model_ms";
884
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
885
+ candidate: detailResult.candidate,
886
+ criteria,
887
+ config: llmConfig,
888
+ timeoutMs: llmTimeoutMs,
889
+ imageEvidence,
890
+ maxImages: llmImageLimit,
891
+ imageDetail: llmImageDetail
892
+ }));
893
+ } catch (error) {
894
+ llmResult = createFailedLlmResult(error);
895
+ }
856
896
  }
857
897
  }
858
898
 
859
899
  let closeResult = null;
860
900
  if (closeResume) {
861
901
  detailStep = "close_resume_modal";
862
- closeResult = await closeChatResumeModal(client);
902
+ closeResult = await measureTiming(timings, "close_detail_ms", () => closeChatResumeModal(client));
863
903
  }
864
904
  detailResult.close_result = closeResult;
865
905
  detailResult.image_evidence = imageEvidence;
@@ -901,6 +941,26 @@ export async function runChatWorkflow({
901
941
  await runControl.waitIfPaused();
902
942
  runControl.throwIfCanceled();
903
943
  runControl.setPhase("chat:screening");
944
+ let cardOnlyLlmResult = null;
945
+ if (useLlmScreening && !detailUnavailableReason && !detailResult?.llm_result) {
946
+ if (!llmConfig) {
947
+ cardOnlyLlmResult = createMissingLlmConfigResult();
948
+ } else {
949
+ try {
950
+ cardOnlyLlmResult = await measureTiming(timings, "text_model_ms", () => callScreeningLlm({
951
+ candidate: screeningCandidate,
952
+ criteria,
953
+ config: llmConfig,
954
+ timeoutMs: llmTimeoutMs,
955
+ maxImages: llmImageLimit,
956
+ imageDetail: llmImageDetail
957
+ }));
958
+ } catch (error) {
959
+ cardOnlyLlmResult = createFailedLlmResult(error);
960
+ }
961
+ }
962
+ }
963
+ const effectiveLlmResult = detailResult?.llm_result || cardOnlyLlmResult;
904
964
  const screening = detailUnavailableReason
905
965
  ? {
906
966
  status: "skip",
@@ -909,15 +969,15 @@ export async function runChatWorkflow({
909
969
  reasons: [detailUnavailableReason],
910
970
  candidate: screeningCandidate
911
971
  }
912
- : detailResult?.llm_result
913
- ? llmToScreening(detailResult.llm_result, screeningCandidate)
972
+ : useLlmScreening
973
+ ? llmToScreening(effectiveLlmResult, screeningCandidate)
914
974
  : screenCandidate(screeningCandidate, { criteria });
915
975
  let postAction = null;
916
976
  if (requestResumeForPassed && screening.passed) {
917
- postAction = await requestChatResumeForPassedCandidate(client, {
977
+ postAction = await measureTiming(timings, "post_action_ms", () => requestChatResumeForPassedCandidate(client, {
918
978
  greetingText,
919
979
  dryRun: dryRunRequestCv
920
- });
980
+ }));
921
981
  if (postAction?.requested) requestSatisfiedCount += 1;
922
982
  if (postAction?.skipped) requestSkippedCount += 1;
923
983
  if (postAction?.requested && !postAction?.skipped) requestedCount += 1;
@@ -925,15 +985,18 @@ export async function runChatWorkflow({
925
985
  throw new Error(`REQUEST_CV_NOT_VERIFIED:${postAction?.reason || "unknown"}`);
926
986
  }
927
987
  }
988
+ timings.total_ms = Date.now() - candidateStarted;
928
989
  const compactResult = {
929
990
  index,
930
991
  candidate_key: candidateKey,
931
992
  card_node_id: effectiveCardNodeId,
932
993
  candidate: compactCandidate(screeningCandidate),
933
994
  detail: compactDetail(detailResult),
995
+ llm_screening: detailResult ? null : compactLlmResult(cardOnlyLlmResult),
934
996
  screening: compactScreening(screening),
935
997
  post_action: postAction,
936
- pre_action_state: preActionState
998
+ pre_action_state: preActionState,
999
+ timings
937
1000
  };
938
1001
  results.push(compactResult);
939
1002
  markInfiniteListCandidateProcessed(listState, candidateKey, {
@@ -951,7 +1014,7 @@ export async function runChatWorkflow({
951
1014
  processed: results.length,
952
1015
  screened: results.length,
953
1016
  detail_opened: results.filter(resultOpenedDetail).length,
954
- llm_screened: results.filter((item) => item.detail?.llm_screening).length,
1017
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
955
1018
  passed: results.filter((item) => item.screening.passed).length,
956
1019
  requested: requestedCount,
957
1020
  request_satisfied: requestSatisfiedCount,
@@ -965,7 +1028,9 @@ export async function runChatWorkflow({
965
1028
  last_candidate_key: candidateKey,
966
1029
  last_score: screening.score
967
1030
  });
1031
+ const checkpointStarted = Date.now();
968
1032
  runControl.checkpoint({
1033
+ results,
969
1034
  last_candidate: {
970
1035
  id: screeningCandidate.id || null,
971
1036
  key: candidateKey,
@@ -974,12 +1039,17 @@ export async function runChatWorkflow({
974
1039
  status: screening.status,
975
1040
  passed: screening.passed,
976
1041
  score: screening.score
977
- }
1042
+ },
1043
+ llm_screening: compactLlmResult(effectiveLlmResult)
978
1044
  }
979
1045
  });
1046
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
980
1047
 
981
1048
  if (delayMs > 0) {
1049
+ const sleepStarted = Date.now();
982
1050
  await runControl.sleep(delayMs);
1051
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1052
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
983
1053
  }
984
1054
  }
985
1055
 
@@ -1002,7 +1072,7 @@ export async function runChatWorkflow({
1002
1072
  processed: results.length,
1003
1073
  screened: results.length,
1004
1074
  detail_opened: results.filter(resultOpenedDetail).length,
1005
- llm_screened: results.filter((item) => item.detail?.llm_screening).length,
1075
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1006
1076
  passed: results.filter((item) => item.screening.passed).length,
1007
1077
  requested: requestedCount,
1008
1078
  request_satisfied: requestSatisfiedCount,
@@ -1027,7 +1097,7 @@ export function createChatRunService({
1027
1097
  maxCandidates = 5,
1028
1098
  targetPassCount = null,
1029
1099
  processUntilListEnd = false,
1030
- detailLimit = 0,
1100
+ detailLimit = null,
1031
1101
  detailSource = "cascade",
1032
1102
  closeResume = true,
1033
1103
  requestResumeForPassed = false,
@@ -1046,15 +1116,20 @@ export function createChatRunService({
1046
1116
  llmTimeoutMs = 120000,
1047
1117
  llmImageLimit = 8,
1048
1118
  llmImageDetail = "high",
1119
+ screeningMode = "llm",
1049
1120
  listMaxScrolls = 20,
1050
1121
  listStableSignatureLimit = 2,
1051
1122
  listWheelDeltaY = 850,
1052
1123
  listSettleMs = 1200,
1053
1124
  listFallbackPoint = null,
1125
+ imageOutputDir = "",
1054
1126
  name = "chat-domain-run"
1055
1127
  } = {}) {
1056
1128
  if (!client) throw new Error("startChatRun requires a guarded CDP client");
1057
1129
  const normalizedDetailSource = normalizeDetailSource(detailSource);
1130
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1131
+ const processedLimit = Math.max(1, Number(maxCandidates) || 1);
1132
+ const normalizedDetailLimit = detailLimit == null ? processedLimit : Math.max(0, Number(detailLimit) || 0);
1058
1133
  return manager.startRun({
1059
1134
  name,
1060
1135
  context: {
@@ -1066,11 +1141,16 @@ export function createChatRunService({
1066
1141
  max_candidates: maxCandidates,
1067
1142
  target_pass_count: targetPassCount,
1068
1143
  process_until_list_end: Boolean(processUntilListEnd),
1069
- detail_limit: detailLimit,
1144
+ detail_limit: normalizedDetailLimit,
1070
1145
  detail_source: normalizedDetailSource,
1071
1146
  close_resume: closeResume,
1072
1147
  cv_acquisition_mode: cvAcquisitionMode,
1073
1148
  call_llm_on_image: Boolean(callLlmOnImage),
1149
+ screening_mode: normalizedScreeningMode,
1150
+ llm_configured: Boolean(llmConfig),
1151
+ llm_timeout_ms: llmTimeoutMs,
1152
+ llm_image_limit: llmImageLimit,
1153
+ llm_image_detail: llmImageDetail,
1074
1154
  max_image_pages: maxImagePages,
1075
1155
  image_wheel_delta_y: imageWheelDeltaY,
1076
1156
  list_max_scrolls: listMaxScrolls,
@@ -1078,13 +1158,14 @@ export function createChatRunService({
1078
1158
  list_wheel_delta_y: listWheelDeltaY,
1079
1159
  list_settle_ms: listSettleMs,
1080
1160
  list_fallback_point: listFallbackPoint,
1081
- online_resume_button_timeout_ms: onlineResumeButtonTimeoutMs
1161
+ online_resume_button_timeout_ms: onlineResumeButtonTimeoutMs,
1162
+ image_output_dir: imageOutputDir || ""
1082
1163
  },
1083
1164
  progress: {
1084
1165
  card_count: 0,
1085
- target_count: targetPassCount || (processUntilListEnd ? "all" : Math.max(1, Number(maxCandidates) || 1)),
1166
+ target_count: targetPassCount || (processUntilListEnd ? "all" : processedLimit),
1086
1167
  target_pass_count: targetPassCount,
1087
- processed_limit: Math.max(1, Number(maxCandidates) || 1),
1168
+ processed_limit: processedLimit,
1088
1169
  processed: 0,
1089
1170
  screened: 0,
1090
1171
  detail_opened: 0,
@@ -1104,7 +1185,7 @@ export function createChatRunService({
1104
1185
  maxCandidates,
1105
1186
  targetPassCount,
1106
1187
  processUntilListEnd,
1107
- detailLimit,
1188
+ detailLimit: normalizedDetailLimit,
1108
1189
  detailSource: normalizedDetailSource,
1109
1190
  closeResume,
1110
1191
  requestResumeForPassed,
@@ -1123,11 +1204,13 @@ export function createChatRunService({
1123
1204
  llmTimeoutMs,
1124
1205
  llmImageLimit,
1125
1206
  llmImageDetail,
1207
+ screeningMode: normalizedScreeningMode,
1126
1208
  listMaxScrolls,
1127
1209
  listStableSignatureLimit,
1128
1210
  listWheelDeltaY,
1129
1211
  listSettleMs,
1130
- listFallbackPoint
1212
+ listFallbackPoint,
1213
+ imageOutputDir
1131
1214
  }, runControl)
1132
1215
  });
1133
1216
  }
@@ -6,6 +6,10 @@ import {
6
6
  querySelectorAll,
7
7
  sleep
8
8
  } from "../../core/browser/index.js";
9
+ import {
10
+ mergeBossCandidateCardFields,
11
+ parseBossCandidateCardFieldsFromHtml
12
+ } from "../../core/boss-cards/index.js";
9
13
  import {
10
14
  htmlToText,
11
15
  normalizeCandidateFromHtml,
@@ -24,6 +28,16 @@ function normalizeRefreshButtonLabel(outerHTML = "") {
24
28
  return normalizeText(htmlToText(outerHTML)).replace(/\s+/g, "");
25
29
  }
26
30
 
31
+ export function parseRecommendCardFieldsFromHtml(html = "") {
32
+ return parseBossCandidateCardFieldsFromHtml(html);
33
+ }
34
+
35
+ function enrichRecommendCardCandidate(candidate, outerHTML = "") {
36
+ return mergeBossCandidateCardFields(candidate, outerHTML, {
37
+ metadataKey: "recommend_card_fields"
38
+ });
39
+ }
40
+
27
41
  function isRefreshButtonLabel(label = "") {
28
42
  const normalized = String(label || "").trim();
29
43
  if (!normalized || normalized.length > 80) return false;
@@ -91,7 +105,7 @@ export async function readRecommendCardCandidate(client, cardNodeId, {
91
105
  getAttributesMap(client, cardNodeId),
92
106
  getOuterHTML(client, cardNodeId)
93
107
  ]);
94
- return normalizeCandidateFromHtml({
108
+ const candidate = normalizeCandidateFromHtml({
95
109
  domain: "recommend",
96
110
  source,
97
111
  html: outerHTML,
@@ -102,6 +116,7 @@ export async function readRecommendCardCandidate(client, cardNodeId, {
102
116
  ...metadata
103
117
  }
104
118
  });
119
+ return enrichRecommendCardCandidate(candidate, outerHTML);
105
120
  }
106
121
 
107
122
  export async function readFirstRecommendCardCandidate(client, frameNodeId, options = {}) {
@@ -279,15 +279,25 @@ export async function openRecommendCardDetail(client, cardNodeId, {
279
279
  timeoutMs = 12000,
280
280
  scrollIntoView = true
281
281
  } = {}) {
282
+ const started = Date.now();
283
+ const clickStarted = Date.now();
282
284
  const cardBox = await clickNodeCenter(client, cardNodeId, { scrollIntoView });
285
+ const candidateClickMs = Date.now() - clickStarted;
286
+ const detailStarted = Date.now();
283
287
  const detailState = await waitForRecommendDetail(client, { timeoutMs });
288
+ const detailOpenMs = Date.now() - detailStarted;
284
289
  if (!detailState?.popup && !detailState?.resumeIframe) {
285
290
  throw new Error("Candidate detail did not open or no known detail selectors mounted");
286
291
  }
287
292
 
288
293
  return {
289
294
  card_box: cardBox,
290
- detail_state: detailState
295
+ detail_state: detailState,
296
+ timings: {
297
+ candidate_click_ms: candidateClickMs,
298
+ detail_open_ms: detailOpenMs,
299
+ open_total_ms: Date.now() - started
300
+ }
291
301
  };
292
302
  }
293
303