@reconcrap/boss-recommend-mcp 2.0.16 → 2.0.18

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.
@@ -4,7 +4,10 @@
4
4
  "model": "gpt-4.1-mini",
5
5
  "llmThinkingLevel": "low",
6
6
  "llmTimeoutMs": 60000,
7
+ "llmMaxTokens": 512,
7
8
  "llmMaxRetries": 3,
9
+ "llmImageLimit": 8,
10
+ "llmImageDetail": "low",
8
11
  "debugPort": 9222,
9
12
  "outputDir": "",
10
13
  "humanRestEnabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/chat-mcp.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import {
35
35
  CHAT_TARGET_URL,
36
36
  closeChatResumeModal,
37
+ closeChatJobDropdown,
37
38
  createChatRunService,
38
39
  getChatRoots,
39
40
  isForbiddenChatResumeTopLevelUrl,
@@ -833,7 +834,18 @@ async function connectChatChromeSession({
833
834
 
834
835
  async function readChatJobOptionsFromSession(session) {
835
836
  const roots = await getChatRoots(session.client);
836
- return readChatJobOptions(session.client, roots.rootNodes.top);
837
+ const result = await readChatJobOptions(session.client, roots.rootNodes.top);
838
+ try {
839
+ result.menu_close = await closeChatJobDropdown(session.client, roots.rootNodes.top);
840
+ } catch (error) {
841
+ result.menu_close = {
842
+ ok: false,
843
+ closed: false,
844
+ reason: "close_failed",
845
+ error: error?.message || String(error)
846
+ };
847
+ }
848
+ return result;
837
849
  }
838
850
 
839
851
  function normalizeChatStartInput(args = {}, configResolution = null) {
@@ -10,6 +10,7 @@ const SCREEN_CONFIG_TEMPLATE_DEFAULTS = Object.freeze({
10
10
  apiKey: "replace-with-your-api-key",
11
11
  model: "gpt-4.1-mini"
12
12
  });
13
+ const LLM_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "auto", "current"]);
13
14
 
14
15
  export const TARGET_COUNT_CANONICAL_ALL = "all";
15
16
  export const TARGET_COUNT_ACCEPTED_EXAMPLES = [TARGET_COUNT_CANONICAL_ALL, -1, 20, "全部候选人"];
@@ -223,6 +224,12 @@ function parsePositiveInteger(raw, fallback = null) {
223
224
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
224
225
  }
225
226
 
227
+ function parseConfigNumber(raw, fallback = null) {
228
+ if (raw === undefined || raw === null || raw === "") return fallback;
229
+ const parsed = Number(raw);
230
+ return Number.isFinite(parsed) ? parsed : fallback;
231
+ }
232
+
226
233
  function parseConfigBoolean(raw, fallback = false) {
227
234
  if (typeof raw === "boolean") return raw;
228
235
  const normalized = normalizeText(raw).toLowerCase();
@@ -231,6 +238,11 @@ function parseConfigBoolean(raw, fallback = false) {
231
238
  return fallback;
232
239
  }
233
240
 
241
+ function normalizeLlmThinkingLevel(raw, fallback = "low") {
242
+ const normalized = normalizeText(raw).toLowerCase();
243
+ return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
244
+ }
245
+
234
246
  function resolveConfigPathValue(raw, configDir) {
235
247
  const normalized = normalizeText(raw);
236
248
  if (!normalized) return "";
@@ -387,8 +399,18 @@ export function resolveBossScreeningConfig(workspaceRoot) {
387
399
  baseUrl: normalizeText(parsed.baseUrl).replace(/\/+$/, ""),
388
400
  apiKey: normalizeText(parsed.apiKey),
389
401
  model: normalizeText(parsed.model),
402
+ openaiOrganization: normalizeText(parsed.openaiOrganization || parsed.organization),
403
+ openaiProject: normalizeText(parsed.openaiProject || parsed.project),
390
404
  debugPort: parsePositiveInteger(parsed.debugPort, 9222),
391
- llmThinkingLevel: normalizeText(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort),
405
+ llmThinkingLevel: normalizeLlmThinkingLevel(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort, "low"),
406
+ llmTimeoutMs: parsePositiveInteger(parsed.llmTimeoutMs || parsed.timeoutMs, null),
407
+ llmMaxRetries: parsePositiveInteger(parsed.llmMaxRetries || parsed.maxRetries, null),
408
+ llmMaxTokens: parsePositiveInteger(parsed.llmMaxTokens || parsed.maxTokens, null),
409
+ llmMaxCompletionTokens: parsePositiveInteger(parsed.llmMaxCompletionTokens || parsed.maxCompletionTokens, null),
410
+ llmImageLimit: parsePositiveInteger(parsed.llmImageLimit || parsed.imageLimit, null),
411
+ llmImageDetail: normalizeText(parsed.llmImageDetail || parsed.imageDetail),
412
+ temperature: parseConfigNumber(parsed.temperature, null),
413
+ topP: parseConfigNumber(parsed.topP || parsed.top_p, null),
392
414
  outputDir: resolveConfigPathValue(parsed.outputDir, configDir),
393
415
  humanRestEnabled: parseConfigBoolean(parsed.humanRestEnabled, false)
394
416
  },
package/src/cli.js CHANGED
@@ -55,6 +55,10 @@ const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
55
55
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
56
56
  const installConfigDefaults = Object.freeze({
57
57
  llmThinkingLevel: "low",
58
+ llmMaxTokens: 512,
59
+ llmMaxRetries: 3,
60
+ llmImageLimit: 8,
61
+ llmImageDetail: "low",
58
62
  humanRestEnabled: false
59
63
  });
60
64
  const bossChatRuntimeChildDirs = ["logs", "runs", "profiles", "reports", "artifacts", "state"];
@@ -246,6 +246,190 @@ function pickEvenly(items = [], limit = 1) {
246
246
  return Array.from(new Map(picked.map((item) => [item.node_id, item])).values());
247
247
  }
248
248
 
249
+ function patternLabel(pattern) {
250
+ if (pattern instanceof RegExp) return pattern.source;
251
+ return normalizeText(pattern);
252
+ }
253
+
254
+ function stopBoundaryPatterns(patterns = []) {
255
+ return (Array.isArray(patterns) ? patterns : [patterns])
256
+ .filter(Boolean)
257
+ .map((pattern) => {
258
+ if (pattern instanceof RegExp) {
259
+ return {
260
+ raw: pattern,
261
+ label: pattern.source,
262
+ matches: (text) => pattern.test(text)
263
+ };
264
+ }
265
+ const normalized = normalizeText(pattern);
266
+ return {
267
+ raw: pattern,
268
+ label: normalized,
269
+ matches: (text) => normalized && text.includes(normalized)
270
+ };
271
+ });
272
+ }
273
+
274
+ async function collectStopBoundaryNodes(client, rootNodeId, {
275
+ selector = "",
276
+ textPatterns = [],
277
+ maxProbeNodes = 180,
278
+ maxTextLength = 700,
279
+ stepTimeoutMs = 45000
280
+ } = {}) {
281
+ const patterns = stopBoundaryPatterns(textPatterns);
282
+ const normalizedSelector = normalizeText(selector);
283
+ if (!normalizedSelector && !patterns.length) {
284
+ return {
285
+ enabled: false,
286
+ ok: false,
287
+ reason: "not_configured",
288
+ nodes: []
289
+ };
290
+ }
291
+ const started = Date.now();
292
+ let nodeIds = [];
293
+ try {
294
+ nodeIds = uniqueNumbers(await querySelectorAll(
295
+ client,
296
+ rootNodeId,
297
+ normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR
298
+ ));
299
+ } catch (error) {
300
+ return {
301
+ enabled: true,
302
+ ok: false,
303
+ reason: "query_selector_all_failed",
304
+ selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
305
+ error: error?.message || String(error),
306
+ nodes: []
307
+ };
308
+ }
309
+
310
+ const probeLimit = Math.max(1, Number(maxProbeNodes) || 180);
311
+ const maxStopTextLength = Math.max(40, Number(maxTextLength) || 700);
312
+ const perNodeTimeoutMs = Math.min(1000, Math.max(200, Math.floor((Number(stepTimeoutMs) || 45000) / 40)));
313
+ const nodes = [];
314
+ for (const nodeId of nodeIds.slice(0, probeLimit)) {
315
+ try {
316
+ let text = "";
317
+ let matchedPattern = null;
318
+ if (patterns.length) {
319
+ const outerHTML = await withCaptureTimeout(getOuterHTML(client, nodeId), {
320
+ label: `stop_boundary_html_${nodeId}`,
321
+ timeoutMs: perNodeTimeoutMs
322
+ });
323
+ text = normalizeText(htmlToText(outerHTML));
324
+ if (!text || text.length > maxStopTextLength) continue;
325
+ matchedPattern = patterns.find((pattern) => pattern.matches(text));
326
+ if (!matchedPattern) continue;
327
+ }
328
+ nodes.push({
329
+ node_id: nodeId,
330
+ text_preview: text.slice(0, 120),
331
+ matched_pattern: matchedPattern ? patternLabel(matchedPattern.raw) : null
332
+ });
333
+ } catch {}
334
+ }
335
+
336
+ return {
337
+ enabled: true,
338
+ ok: nodes.length > 0,
339
+ reason: nodes.length ? null : "no_matching_stop_boundary_nodes",
340
+ selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
341
+ elapsed_ms: Date.now() - started,
342
+ discovered_node_count: nodeIds.length,
343
+ probed_node_count: Math.min(nodeIds.length, probeLimit),
344
+ match_count: nodes.length,
345
+ pattern_labels: patterns.map((pattern) => pattern.label),
346
+ nodes
347
+ };
348
+ }
349
+
350
+ async function resolveVisibleStopBoundary(client, stopBoundaryPlan, clip, {
351
+ topPadding = 8,
352
+ minCaptureHeight = 180,
353
+ stepTimeoutMs = 45000
354
+ } = {}) {
355
+ if (!stopBoundaryPlan?.nodes?.length || !clip) return null;
356
+ const clipTop = Number(clip.y) || 0;
357
+ const clipBottom = clipTop + (Number(clip.height) || 0);
358
+ const safePadding = Math.max(0, Number(topPadding) || 0);
359
+ const safeMinHeight = Math.max(1, Number(minCaptureHeight) || 180);
360
+ const perNodeTimeoutMs = Math.min(900, Math.max(180, Math.floor((Number(stepTimeoutMs) || 45000) / 50)));
361
+ const visible = [];
362
+
363
+ for (const node of stopBoundaryPlan.nodes) {
364
+ try {
365
+ const box = await withCaptureTimeout(getNodeBox(client, node.node_id), {
366
+ label: `stop_boundary_box_${node.node_id}`,
367
+ timeoutMs: perNodeTimeoutMs
368
+ });
369
+ const rect = box?.rect || {};
370
+ const width = Number(rect.width) || 0;
371
+ const height = Number(rect.height) || 0;
372
+ if (width < 40 || height < 6) continue;
373
+ const top = Number(rect.y) || 0;
374
+ const bottom = top + height;
375
+ if (bottom <= clipTop + 1) {
376
+ return {
377
+ action: "stop_before_capture",
378
+ reason: "stop_boundary_above_clip",
379
+ node_id: node.node_id,
380
+ matched_pattern: node.matched_pattern,
381
+ text_preview: node.text_preview,
382
+ rect,
383
+ clip
384
+ };
385
+ }
386
+ if (top < clipBottom && bottom > clipTop) {
387
+ visible.push({
388
+ ...node,
389
+ rect,
390
+ top,
391
+ bottom
392
+ });
393
+ }
394
+ } catch {}
395
+ }
396
+ if (!visible.length) return null;
397
+
398
+ visible.sort((a, b) => a.top - b.top);
399
+ const boundary = visible[0];
400
+ const boundaryY = Math.max(clipTop, boundary.top - safePadding);
401
+ const adjustedHeight = Math.max(0, boundaryY - clipTop);
402
+ if (adjustedHeight < safeMinHeight) {
403
+ return {
404
+ action: "stop_before_capture",
405
+ reason: "stop_boundary_near_clip_top",
406
+ node_id: boundary.node_id,
407
+ matched_pattern: boundary.matched_pattern,
408
+ text_preview: boundary.text_preview,
409
+ rect: boundary.rect,
410
+ clip,
411
+ adjusted_height: adjustedHeight,
412
+ min_capture_height: safeMinHeight
413
+ };
414
+ }
415
+
416
+ return {
417
+ action: "capture_then_stop",
418
+ reason: "stop_boundary_visible",
419
+ node_id: boundary.node_id,
420
+ matched_pattern: boundary.matched_pattern,
421
+ text_preview: boundary.text_preview,
422
+ rect: boundary.rect,
423
+ clip,
424
+ adjusted_clip: {
425
+ ...clip,
426
+ height: adjustedHeight
427
+ },
428
+ adjusted_height: adjustedHeight,
429
+ min_capture_height: safeMinHeight
430
+ };
431
+ }
432
+
249
433
  async function collectDomScrollAnchors(client, rootNodeId, {
250
434
  selector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
251
435
  maxScreenshots = 6,
@@ -525,6 +709,12 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
525
709
  scrollAnchorSelector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
526
710
  scrollAnchorMaxProbeNodes = 260,
527
711
  scrollAnchorMinGap = 180,
712
+ stopBoundarySelector = "",
713
+ stopBoundaryTextPatterns = [],
714
+ stopBoundaryMaxProbeNodes = 180,
715
+ stopBoundaryMaxTextLength = 700,
716
+ stopBoundaryTopPadding = 8,
717
+ stopBoundaryMinCaptureHeight = 180,
528
718
  metadata = {}
529
719
  } = {}) {
530
720
  if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
@@ -540,11 +730,26 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
540
730
  stepTimeoutMs
541
731
  })
542
732
  : null;
733
+ const stopBoundaryEnabled = Boolean(
734
+ normalizeText(stopBoundarySelector)
735
+ || (Array.isArray(stopBoundaryTextPatterns)
736
+ ? stopBoundaryTextPatterns.length
737
+ : stopBoundaryTextPatterns)
738
+ );
739
+ let stopBoundaryPlan = {
740
+ enabled: false,
741
+ ok: false,
742
+ reason: "not_configured",
743
+ nodes: []
744
+ };
745
+ const stopBoundaryChecks = [];
543
746
  const screenshots = [];
544
747
  let consecutiveDuplicates = 0;
545
748
  let previousHash = "";
546
749
  let captureCount = 0;
547
750
  let droppedDuplicateCount = 0;
751
+ let forceInputScrollAfterDuplicate = false;
752
+ let stopBoundaryResult = null;
548
753
  let currentScrollMetadata = {
549
754
  before_capture: "initial",
550
755
  method: normalizedScrollMethod,
@@ -597,7 +802,37 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
597
802
  timeoutMs: stepTimeoutMs
598
803
  });
599
804
  const clip = withPadding(box.rect, padding);
600
- const captureOptions = captureViewport ? {
805
+ let visibleStopBoundary = null;
806
+ if (stopBoundaryEnabled) {
807
+ stopBoundaryPlan = await collectStopBoundaryNodes(client, nodeId, {
808
+ selector: stopBoundarySelector,
809
+ textPatterns: stopBoundaryTextPatterns,
810
+ maxProbeNodes: stopBoundaryMaxProbeNodes,
811
+ maxTextLength: stopBoundaryMaxTextLength,
812
+ stepTimeoutMs
813
+ });
814
+ stopBoundaryChecks.push({
815
+ capture_index: index,
816
+ ok: Boolean(stopBoundaryPlan.ok),
817
+ reason: stopBoundaryPlan.reason || null,
818
+ discovered_node_count: stopBoundaryPlan.discovered_node_count || 0,
819
+ probed_node_count: stopBoundaryPlan.probed_node_count || 0,
820
+ match_count: stopBoundaryPlan.match_count || 0,
821
+ elapsed_ms: stopBoundaryPlan.elapsed_ms || 0
822
+ });
823
+ visibleStopBoundary = await resolveVisibleStopBoundary(client, stopBoundaryPlan, clip, {
824
+ topPadding: stopBoundaryTopPadding,
825
+ minCaptureHeight: stopBoundaryMinCaptureHeight,
826
+ stepTimeoutMs
827
+ });
828
+ }
829
+ if (visibleStopBoundary?.action === "stop_before_capture") {
830
+ stopBoundaryResult = visibleStopBoundary;
831
+ break;
832
+ }
833
+ const effectiveClip = visibleStopBoundary?.adjusted_clip || clip;
834
+ const effectiveCaptureViewport = Boolean(captureViewport && !visibleStopBoundary?.adjusted_clip);
835
+ const captureOptions = effectiveCaptureViewport ? {
601
836
  format,
602
837
  fromSurface,
603
838
  captureBeyondViewport: false
@@ -605,7 +840,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
605
840
  format,
606
841
  fromSurface,
607
842
  captureBeyondViewport,
608
- clip
843
+ clip: effectiveClip
609
844
  };
610
845
  if (quality != null) {
611
846
  captureOptions.quality = quality;
@@ -658,16 +893,30 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
658
893
  file_path: outputPath,
659
894
  sha256: hash,
660
895
  duplicate_of_previous: Boolean(duplicateOfPrevious),
661
- clip,
662
- capture_viewport: Boolean(captureViewport),
896
+ clip: effectiveClip,
897
+ capture_viewport: effectiveCaptureViewport,
663
898
  node_rect: box.rect,
664
899
  scroll: currentScrollMetadata,
900
+ stop_boundary: visibleStopBoundary || null,
665
901
  metadata
666
902
  });
667
903
  }
668
904
 
905
+ if (visibleStopBoundary?.action === "capture_then_stop") {
906
+ stopBoundaryResult = visibleStopBoundary;
907
+ break;
908
+ }
909
+
669
910
  previousHash = hash;
670
- if (consecutiveDuplicates >= Math.max(1, Number(duplicateStopCount) || 1)) {
911
+ forceInputScrollAfterDuplicate = Boolean(
912
+ duplicateOfPrevious
913
+ && normalizedScrollMethod === "dom-anchor-fallback-input"
914
+ && currentScrollMetadata?.method === "DOM.scrollIntoViewIfNeeded"
915
+ );
916
+ if (
917
+ consecutiveDuplicates >= Math.max(1, Number(duplicateStopCount) || 1)
918
+ && !forceInputScrollAfterDuplicate
919
+ ) {
671
920
  break;
672
921
  }
673
922
 
@@ -675,7 +924,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
675
924
  assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `scroll_after_page_${index + 1}`);
676
925
  let scrolledByDomAnchor = false;
677
926
  const nextAnchor = anchorPlan?.anchors?.[index + 1] || null;
678
- if (nextAnchor?.node_id && normalizedScrollMethod !== "input") {
927
+ if (nextAnchor?.node_id && normalizedScrollMethod !== "input" && !forceInputScrollAfterDuplicate) {
679
928
  try {
680
929
  await scrollDomAnchorIntoView(client, nextAnchor.node_id, {
681
930
  label: `scroll_dom_anchor_${index + 1}`,
@@ -782,12 +1031,15 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
782
1031
  step_timeout_ms: Math.max(0, Number(stepTimeoutMs) || 0),
783
1032
  total_timeout_ms: Math.max(0, Number(totalTimeoutMs) || 0),
784
1033
  scroll_method: normalizedScrollMethod,
785
- scroll_anchor_selector: scrollAnchorSelector,
786
- scroll_anchor_max_probe_nodes: Math.max(1, Number(scrollAnchorMaxProbeNodes) || 260),
787
- scroll_anchor_min_gap: Math.max(0, Number(scrollAnchorMinGap) || 0)
788
- },
789
- scroll_anchor_plan: anchorPlan,
790
- file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
1034
+ scroll_anchor_selector: scrollAnchorSelector,
1035
+ scroll_anchor_max_probe_nodes: Math.max(1, Number(scrollAnchorMaxProbeNodes) || 260),
1036
+ scroll_anchor_min_gap: Math.max(0, Number(scrollAnchorMinGap) || 0)
1037
+ },
1038
+ scroll_anchor_plan: anchorPlan,
1039
+ stop_boundary_plan: stopBoundaryPlan,
1040
+ stop_boundary_checks: stopBoundaryChecks,
1041
+ stop_boundary_result: stopBoundaryResult,
1042
+ file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
791
1043
  screenshots,
792
1044
  metadata
793
1045
  };
@@ -138,6 +138,10 @@ export function summarizeImageEvidence(imageEvidence = null) {
138
138
  llm_original_total_byte_length: imageEvidence.llm_original_total_byte_length || 0,
139
139
  llm_composition_error: imageEvidence.llm_composition_error || null,
140
140
  optimization: imageEvidence.optimization || null,
141
+ scroll_anchor_plan: imageEvidence.scroll_anchor_plan || null,
142
+ stop_boundary_plan: imageEvidence.stop_boundary_plan || null,
143
+ stop_boundary_checks: imageEvidence.stop_boundary_checks || [],
144
+ stop_boundary_result: imageEvidence.stop_boundary_result || null,
141
145
  error_code: imageEvidence.error_code || imageEvidence.code || null,
142
146
  error: imageEvidence.error || null,
143
147
  file_paths: imageEvidence.file_paths || [],
@@ -77,6 +77,31 @@ function applyChatCompletionThinking(payload, { baseUrl = "", model = "", thinki
77
77
  return payload;
78
78
  }
79
79
 
80
+ function parsePositiveNumber(value, fallback = null) {
81
+ if (value === undefined || value === null || value === "") return fallback;
82
+ const parsed = Number(value);
83
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
84
+ }
85
+
86
+ function parseFiniteNumber(value, fallback = null) {
87
+ if (value === undefined || value === null || value === "") return fallback;
88
+ const parsed = Number(value);
89
+ return Number.isFinite(parsed) ? parsed : fallback;
90
+ }
91
+
92
+ function resolveLlmOutputTokenBudget(config = {}, thinkingLevel = "") {
93
+ const explicit = parsePositiveNumber(
94
+ config.llmMaxCompletionTokens
95
+ ?? config.maxCompletionTokens
96
+ ?? config.llmMaxTokens
97
+ ?? config.maxTokens,
98
+ null
99
+ );
100
+ if (explicit) return Math.max(1, Math.floor(explicit));
101
+ const normalizedThinking = normalizeLlmThinkingLevel(thinkingLevel || "low") || "low";
102
+ return normalizedThinking === "off" || normalizedThinking === "minimal" ? 64 : 512;
103
+ }
104
+
80
105
  export function normalizeText(input) {
81
106
  return String(input || "").replace(/\s+/g, " ").trim();
82
107
  }
@@ -1436,20 +1461,31 @@ export async function callScreeningLlm({
1436
1461
  throw new Error("Candidate text and image evidence are empty");
1437
1462
  }
1438
1463
 
1464
+ const thinkingLevel = config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low";
1465
+ const outputTokenBudget = resolveLlmOutputTokenBudget(config, thinkingLevel);
1439
1466
  const payload = {
1440
1467
  model,
1441
- temperature: 0.1,
1442
- max_tokens: Math.max(1, Number(config.maxTokens || config.llmMaxTokens) || 64),
1468
+ temperature: parseFiniteNumber(config.temperature, 0.1),
1469
+ max_tokens: outputTokenBudget,
1443
1470
  messages: buildScreeningLlmMessages({
1444
1471
  candidate,
1445
1472
  criteria,
1446
1473
  imageInputs
1447
1474
  })
1448
1475
  };
1476
+ const topP = parseFiniteNumber(config.topP ?? config.top_p, null);
1477
+ if (topP !== null) payload.top_p = topP;
1478
+ const maxCompletionTokens = parsePositiveNumber(
1479
+ config.llmMaxCompletionTokens ?? config.maxCompletionTokens,
1480
+ null
1481
+ );
1482
+ if (maxCompletionTokens !== null) {
1483
+ payload.max_completion_tokens = Math.max(1, Math.floor(maxCompletionTokens));
1484
+ }
1449
1485
  applyChatCompletionThinking(payload, {
1450
1486
  baseUrl,
1451
1487
  model,
1452
- thinkingLevel: config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low"
1488
+ thinkingLevel
1453
1489
  });
1454
1490
 
1455
1491
  const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
@@ -1504,7 +1540,12 @@ export async function callScreeningLlm({
1504
1540
  ok: true,
1505
1541
  provider: {
1506
1542
  baseUrl: baseUrl.replace(/\/\/[^/]+/, "//[redacted-host]"),
1507
- model
1543
+ model,
1544
+ thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1545
+ thinking: payload.thinking || null,
1546
+ reasoning_effort: payload.reasoning_effort || null,
1547
+ max_tokens: payload.max_tokens,
1548
+ max_completion_tokens: payload.max_completion_tokens || null
1508
1549
  },
1509
1550
  passed,
1510
1551
  reason: "",
@@ -4,6 +4,7 @@ import {
4
4
  getAttributesMap,
5
5
  getNodeBox,
6
6
  getOuterHTML,
7
+ pressKey,
7
8
  querySelector,
8
9
  querySelectorAll,
9
10
  sleep
@@ -256,6 +257,80 @@ async function clickFirstVisible(client, rootNodeId, selectors = []) {
256
257
  };
257
258
  }
258
259
 
260
+ async function openChatJobDropdown(client, rootNodeId, {
261
+ timeoutMs = 12000,
262
+ intervalMs = 300,
263
+ settleMs = 800
264
+ } = {}) {
265
+ const started = Date.now();
266
+ const triedPoints = new Set();
267
+ const attempts = [];
268
+ for (const selector of CHAT_JOB_TRIGGER_SELECTORS) {
269
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
270
+ const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
271
+ for (const nodeId of nodeIds) {
272
+ try {
273
+ const box = await getNodeBox(client, nodeId);
274
+ if (box.rect.width <= 2 || box.rect.height <= 2) continue;
275
+ const y = box.center.y;
276
+ const xCandidates = [
277
+ ["center", box.center.x],
278
+ ["right_12", box.rect.x + box.rect.width - 12],
279
+ ["right_44", box.rect.x + box.rect.width - 44],
280
+ ["right_64", box.rect.x + box.rect.width - 64]
281
+ ].filter(([, x]) => x > box.rect.x + 4 && x < box.rect.x + box.rect.width - 4);
282
+ for (const [pointName, x] of xCandidates) {
283
+ const pointKey = `${nodeId}:${Math.round(x)}:${Math.round(y)}`;
284
+ if (triedPoints.has(pointKey)) continue;
285
+ triedPoints.add(pointKey);
286
+ await clickPoint(client, x, y);
287
+ if (settleMs > 0) await sleep(Math.min(settleMs, 800));
288
+ const remaining = Math.max(300, timeoutMs - (Date.now() - started));
289
+ const optionsResult = await waitForChatJobOptions(client, currentRootNodeId, {
290
+ timeoutMs: Math.min(remaining, 1800),
291
+ intervalMs,
292
+ requireVisible: true
293
+ });
294
+ const visibleCount = (optionsResult.job_options || []).filter((option) => option.visible).length;
295
+ const attempt = {
296
+ clicked: true,
297
+ selector,
298
+ node_id: nodeId,
299
+ point: pointName,
300
+ center: { x, y },
301
+ visible_option_count: visibleCount
302
+ };
303
+ attempts.push(attempt);
304
+ if (visibleCount > 0) {
305
+ return {
306
+ ...attempt,
307
+ attempts,
308
+ options_result: optionsResult
309
+ };
310
+ }
311
+ if (Date.now() - started > timeoutMs) break;
312
+ }
313
+ } catch (error) {
314
+ attempts.push({
315
+ clicked: false,
316
+ selector,
317
+ node_id: nodeId,
318
+ error: error?.message || String(error)
319
+ });
320
+ }
321
+ if (Date.now() - started > timeoutMs) break;
322
+ }
323
+ if (Date.now() - started > timeoutMs) break;
324
+ }
325
+ return {
326
+ clicked: attempts.some((attempt) => attempt.clicked),
327
+ selector: attempts.find((attempt) => attempt.clicked)?.selector || "",
328
+ node_id: attempts.find((attempt) => attempt.clicked)?.node_id || 0,
329
+ attempts,
330
+ options_result: null
331
+ };
332
+ }
333
+
259
334
  async function waitForChatJobOptions(client, rootNodeId, {
260
335
  timeoutMs = 12000,
261
336
  intervalMs = 300,
@@ -312,6 +387,83 @@ async function waitForSelectedChatJob(client, rootNodeId, jobLabel = "", {
312
387
  };
313
388
  }
314
389
 
390
+ async function visibleChatJobOptions(client, rootNodeId) {
391
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
392
+ const visible = [];
393
+ for (const selector of CHAT_JOB_OPTION_SELECTORS) {
394
+ const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
395
+ for (const nodeId of nodeIds) {
396
+ try {
397
+ const box = await getNodeBox(client, nodeId);
398
+ if (box.rect.width > 2 && box.rect.height > 2) {
399
+ visible.push({
400
+ selector,
401
+ node_id: nodeId,
402
+ center: box.center,
403
+ rect: box.rect
404
+ });
405
+ }
406
+ } catch {
407
+ // Hidden job options are normal when the dropdown is closed.
408
+ }
409
+ }
410
+ }
411
+ return visible;
412
+ }
413
+
414
+ export async function closeChatJobDropdown(client, rootNodeId, {
415
+ settleMs = 180
416
+ } = {}) {
417
+ const before = await visibleChatJobOptions(client, rootNodeId);
418
+ if (!before.length) {
419
+ return {
420
+ ok: true,
421
+ closed: false,
422
+ reason: "already_closed",
423
+ visible_before_count: 0,
424
+ visible_after_count: 0
425
+ };
426
+ }
427
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
428
+ return {
429
+ ok: false,
430
+ closed: false,
431
+ reason: "dispatch_key_unavailable",
432
+ visible_before_count: before.length,
433
+ visible_after_count: before.length
434
+ };
435
+ }
436
+ await pressKey(client, "Escape", {
437
+ code: "Escape",
438
+ windowsVirtualKeyCode: 27,
439
+ nativeVirtualKeyCode: 27
440
+ });
441
+ if (settleMs > 0) await sleep(settleMs);
442
+ const after = await visibleChatJobOptions(client, rootNodeId);
443
+ return {
444
+ ok: after.length === 0,
445
+ closed: after.length === 0,
446
+ reason: after.length ? "still_visible_after_escape" : "escape",
447
+ visible_before_count: before.length,
448
+ visible_after_count: after.length,
449
+ first_visible_before: before[0] || null,
450
+ first_visible_after: after[0] || null
451
+ };
452
+ }
453
+
454
+ async function closeChatJobDropdownQuietly(client, rootNodeId, settleMs = 180) {
455
+ try {
456
+ return await closeChatJobDropdown(client, rootNodeId, { settleMs });
457
+ } catch (error) {
458
+ return {
459
+ ok: false,
460
+ closed: false,
461
+ reason: "close_failed",
462
+ error: error?.message || String(error)
463
+ };
464
+ }
465
+ }
466
+
315
467
  export async function selectChatJob(client, rootNodeId, {
316
468
  jobLabel = "",
317
469
  timeoutMs = 12000,
@@ -340,6 +492,7 @@ export async function selectChatJob(client, rootNodeId, {
340
492
  || selectedLabelMatches(optionsResult.selected_label, requested)
341
493
  )
342
494
  ) {
495
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
343
496
  return {
344
497
  selected: true,
345
498
  verified: true,
@@ -347,34 +500,41 @@ export async function selectChatJob(client, rootNodeId, {
347
500
  requested,
348
501
  selected_option: matched,
349
502
  options: optionsResult.job_options || [],
350
- selected_label: optionsResult.selected_label || matched.label
503
+ selected_label: optionsResult.selected_label || matched.label,
504
+ menu_close: menuClose
351
505
  };
352
506
  }
353
507
 
354
508
  if (!matched || !matched.visible) {
355
509
  const triggerRootNodeId = await freshTopRootNodeId(client, currentRootNodeId);
356
- const trigger = await clickFirstVisible(client, triggerRootNodeId, CHAT_JOB_TRIGGER_SELECTORS);
357
- if (settleMs > 0) await sleep(settleMs);
510
+ const trigger = await openChatJobDropdown(client, triggerRootNodeId, {
511
+ timeoutMs,
512
+ intervalMs,
513
+ settleMs
514
+ });
358
515
  currentRootNodeId = await freshTopRootNodeId(client, triggerRootNodeId);
359
- optionsResult = await waitForChatJobOptions(client, currentRootNodeId, {
516
+ optionsResult = trigger.options_result || await waitForChatJobOptions(client, currentRootNodeId, {
360
517
  timeoutMs,
361
518
  intervalMs,
362
519
  requireVisible: true
363
520
  });
364
521
  matched = (optionsResult.job_options || []).find((option) => matchJobOption(option, requested)) || null;
365
522
  if (!matched || !matched.visible) {
523
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
366
524
  return {
367
525
  selected: false,
368
526
  reason: matched ? "job_option_not_visible" : "job_option_not_found",
369
527
  requested,
370
528
  trigger,
371
529
  options: optionsResult.job_options || [],
372
- selected_label_before: optionsResult.selected_label || ""
530
+ selected_label_before: optionsResult.selected_label || "",
531
+ menu_close: menuClose
373
532
  };
374
533
  }
375
534
  }
376
535
 
377
536
  if (matched.active || normalizeJobText(optionsResult.selected_label).toLowerCase() === normalizeJobText(matched.label).toLowerCase()) {
537
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
378
538
  return {
379
539
  selected: true,
380
540
  verified: true,
@@ -382,7 +542,8 @@ export async function selectChatJob(client, rootNodeId, {
382
542
  requested,
383
543
  selected_option: matched,
384
544
  options: optionsResult.job_options || [],
385
- selected_label: optionsResult.selected_label || matched.label
545
+ selected_label: optionsResult.selected_label || matched.label,
546
+ menu_close: menuClose
386
547
  };
387
548
  }
388
549
 
@@ -408,6 +569,7 @@ export async function selectChatJob(client, rootNodeId, {
408
569
  const selectedLabel = normalizeJobText(after.selected_label || "");
409
570
  const activeMatch = activeMatchingJobOption(after.job_options || [], matched.label);
410
571
  const verified = Boolean(verification.verified || selectedLabelMatches(selectedLabel, matched.label) || activeMatch);
572
+ const menuClose = await closeChatJobDropdownQuietly(client, afterRootNodeId, Math.min(settleMs, 300));
411
573
 
412
574
  return {
413
575
  selected: verified,
@@ -420,6 +582,7 @@ export async function selectChatJob(client, rootNodeId, {
420
582
  options: after.job_options || optionsResult.job_options || [],
421
583
  selected_label: selectedLabel,
422
584
  before: optionsResult,
423
- after
585
+ after,
586
+ menu_close: menuClose
424
587
  };
425
588
  }
@@ -21,7 +21,8 @@ import {
21
21
  createInfiniteListState,
22
22
  detectInfiniteListBottomMarker,
23
23
  getNextInfiniteListCandidate,
24
- markInfiniteListCandidateProcessed
24
+ markInfiniteListCandidateProcessed,
25
+ resetInfiniteListForRefreshRound
25
26
  } from "../../core/infinite-list/index.js";
26
27
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
27
28
  import { createRunLifecycleManager } from "../../core/run/index.js";
@@ -117,6 +118,22 @@ function compactCandidate(candidate) {
117
118
  };
118
119
  }
119
120
 
121
+ function compactChatJobGuard(result = null) {
122
+ if (!result || typeof result !== "object") return null;
123
+ return {
124
+ selected: Boolean(result.selected),
125
+ verified: Boolean(result.verified),
126
+ already_current: Boolean(result.already_current),
127
+ requested: result.requested || null,
128
+ reason: result.reason || null,
129
+ selected_label: result.selected_label || result.selected_option?.label || null,
130
+ selected_value: result.selected_option?.value || result.active_option?.value || null,
131
+ active_label: result.active_option?.label || null,
132
+ active_value: result.active_option?.value || null,
133
+ menu_close: result.menu_close || null
134
+ };
135
+ }
136
+
120
137
  function compactDetail(detailResult) {
121
138
  if (!detailResult) return null;
122
139
  return {
@@ -146,13 +163,71 @@ function llmToScreening(llmResult, candidate) {
146
163
  };
147
164
  }
148
165
 
149
- function captureNodeIdFromResumeState(resumeState) {
150
- return resumeState?.content?.node_id
151
- || resumeState?.popup?.node_id
166
+ export function captureNodeIdFromResumeState(resumeState) {
167
+ return resumeState?.popup?.node_id
168
+ || resumeState?.content?.node_id
152
169
  || resumeState?.resumeIframe?.node_id
153
170
  || null;
154
171
  }
155
172
 
173
+ export function resolveChatDomFallbackWait({
174
+ normalizedDetailSource = "cascade",
175
+ parsedNetworkProfileCount = 0,
176
+ waitPlan = null,
177
+ resumeDomTimeoutMs = 60000
178
+ } = {}) {
179
+ const detailSource = normalizeDetailSource(normalizedDetailSource);
180
+ const configuredTimeoutMs = Math.max(0, Number(resumeDomTimeoutMs) || 0);
181
+ if (detailSource === "image") {
182
+ return {
183
+ skipped: false,
184
+ timeout_ms: Math.min(configuredTimeoutMs, 3500),
185
+ configured_timeout_ms: configuredTimeoutMs,
186
+ short_probe: true,
187
+ reason: "forced_image_modal_probe"
188
+ };
189
+ }
190
+ if (detailSource === "dom") {
191
+ return {
192
+ skipped: false,
193
+ timeout_ms: configuredTimeoutMs,
194
+ configured_timeout_ms: configuredTimeoutMs,
195
+ short_probe: false,
196
+ reason: "dom_source_full_wait"
197
+ };
198
+ }
199
+
200
+ const profileCount = Math.max(0, Number(parsedNetworkProfileCount) || 0);
201
+ const previousImageMode = waitPlan?.mode_before === "image";
202
+ if (profileCount > 0) {
203
+ return {
204
+ skipped: false,
205
+ timeout_ms: Math.min(configuredTimeoutMs, previousImageMode ? 1500 : 3500),
206
+ configured_timeout_ms: configuredTimeoutMs,
207
+ short_probe: true,
208
+ reason: previousImageMode
209
+ ? "previous_image_mode_profile_only_network_short_dom_probe"
210
+ : "profile_only_network_short_dom_probe"
211
+ };
212
+ }
213
+ if (previousImageMode) {
214
+ return {
215
+ skipped: false,
216
+ timeout_ms: Math.min(configuredTimeoutMs, 2500),
217
+ configured_timeout_ms: configuredTimeoutMs,
218
+ short_probe: true,
219
+ reason: "previous_image_mode_network_miss_short_dom_probe"
220
+ };
221
+ }
222
+ return {
223
+ skipped: false,
224
+ timeout_ms: configuredTimeoutMs,
225
+ configured_timeout_ms: configuredTimeoutMs,
226
+ short_probe: false,
227
+ reason: "cascade_full_dom_wait"
228
+ };
229
+ }
230
+
156
231
  function isRecoverableCdpNodeError(error) {
157
232
  return /(?:Could not find node|No node with given id|Cannot find node|Could not compute box model)/i
158
233
  .test(String(error?.message || error || ""));
@@ -208,6 +283,30 @@ const CHAT_FULL_CV_DOM_MIN_TEXT_LENGTH = 500;
208
283
  const CHAT_FULL_CV_DOM_MIN_SECTION_TEXT_LENGTH = 180;
209
284
  const CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH = 650;
210
285
  const CHAT_FULL_CV_NETWORK_MIN_RICH_ITEM_COUNT = 3;
286
+ const CHAT_RESUME_IMAGE_STOP_BOUNDARY_SELECTOR = [
287
+ "h1",
288
+ "h2",
289
+ "h3",
290
+ "h4",
291
+ "h5",
292
+ "p",
293
+ "span",
294
+ "section",
295
+ "article",
296
+ "div",
297
+ "[class*='privacy']",
298
+ "[class*='recommend']",
299
+ "[class*='similar']"
300
+ ].join(",");
301
+ const CHAT_RESUME_IMAGE_STOP_BOUNDARY_TEXT = Object.freeze([
302
+ /其他名企大厂/,
303
+ /其他.*牛人/,
304
+ /毕业的牛人/,
305
+ /经历牛人/,
306
+ /为妥善保护/,
307
+ /查看全部.*项分析/,
308
+ /牛人分析器/
309
+ ]);
211
310
  const CHAT_FULL_CV_SECTION_PATTERNS = Object.freeze([
212
311
  /教育(?:经历|背景|经验)?/i,
213
312
  /工作(?:经历|经验)?/i,
@@ -738,6 +837,44 @@ export async function runChatWorkflow({
738
837
  });
739
838
  continue;
740
839
  }
840
+ if (normalizeText(job)) {
841
+ const jobGuard = await selectChatJob(client, rootState.rootNodes.top, {
842
+ jobLabel: job,
843
+ timeoutMs: Math.min(readyTimeoutMs, 12000),
844
+ settleMs: Math.min(listSettleMs, 800)
845
+ });
846
+ if (!jobGuard.selected || jobGuard.verified !== true) {
847
+ const error = new Error(`CHAT_JOB_GUARD_FAILED: requested=${job}; selected=${jobGuard.selected_label || "unknown"}; reason=${jobGuard.reason || "unknown"}`);
848
+ error.code = "CHAT_JOB_GUARD_FAILED";
849
+ error.chat_job_guard = compactChatJobGuard(jobGuard);
850
+ throw error;
851
+ }
852
+ if (!jobGuard.already_current) {
853
+ runControl.checkpoint({
854
+ chat_context_step: "job_guard_reselected",
855
+ job_guard: compactChatJobGuard(jobGuard),
856
+ candidate_list: resetInfiniteListForRefreshRound(listState, {
857
+ reason: "chat_job_drift_repaired",
858
+ round: listState.ledger?.length || 0,
859
+ method: "selectChatJob",
860
+ metadata: {
861
+ requested_job: job,
862
+ selected_label: jobGuard.selected_label || "",
863
+ selected_value: jobGuard.selected_option?.value || ""
864
+ }
865
+ })
866
+ });
867
+ rootState = await ensureChatViewport(await getChatRoots(client), "candidate_job_guard_reselected");
868
+ await sleep(Math.min(listSettleMs, 1200));
869
+ continue;
870
+ }
871
+ if (jobGuard.menu_close?.closed) {
872
+ runControl.checkpoint({
873
+ chat_context_step: "job_guard_closed_dropdown",
874
+ job_guard: compactChatJobGuard(jobGuard)
875
+ });
876
+ }
877
+ }
741
878
 
742
879
  const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
743
880
  client,
@@ -962,10 +1099,40 @@ export async function runChatWorkflow({
962
1099
 
963
1100
  if (!detailResult) {
964
1101
  detailStep = "wait_resume_content";
965
- contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
966
- timeoutMs: resumeDomTimeoutMs,
967
- intervalMs: 300
968
- }));
1102
+ const domFallbackPlan = resolveChatDomFallbackWait({
1103
+ normalizedDetailSource,
1104
+ parsedNetworkProfileCount,
1105
+ waitPlan,
1106
+ resumeDomTimeoutMs
1107
+ });
1108
+ if (domFallbackPlan.skipped || domFallbackPlan.timeout_ms <= 0) {
1109
+ contentWait = {
1110
+ ok: false,
1111
+ skipped: true,
1112
+ reason: domFallbackPlan.reason,
1113
+ elapsed_ms: 0,
1114
+ text_length: 0,
1115
+ resume_state: openedResume.resume_state,
1116
+ resume_html: null,
1117
+ dom_fallback_plan: domFallbackPlan,
1118
+ configured_timeout_ms: domFallbackPlan.configured_timeout_ms,
1119
+ timeout_ms: domFallbackPlan.timeout_ms,
1120
+ short_probe: Boolean(domFallbackPlan.short_probe)
1121
+ };
1122
+ addTiming(timings, "dom_fallback_ms", 0);
1123
+ } else {
1124
+ contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
1125
+ timeoutMs: domFallbackPlan.timeout_ms,
1126
+ intervalMs: 300
1127
+ }));
1128
+ contentWait.dom_fallback_plan = domFallbackPlan;
1129
+ contentWait.configured_timeout_ms = domFallbackPlan.configured_timeout_ms;
1130
+ contentWait.timeout_ms = domFallbackPlan.timeout_ms;
1131
+ contentWait.short_probe = Boolean(domFallbackPlan.short_probe);
1132
+ if (domFallbackPlan.short_probe && !contentWait.ok) {
1133
+ contentWait.reason = contentWait.reason || domFallbackPlan.reason;
1134
+ }
1135
+ }
969
1136
  resumeState = contentWait.resume_state || openedResume.resume_state;
970
1137
  resumeHtml = contentWait.resume_html || null;
971
1138
  resumeNetworkEvents = networkRecorder.events.slice();
@@ -1012,23 +1179,29 @@ export async function runChatWorkflow({
1012
1179
  quality: 72,
1013
1180
  optimize: true,
1014
1181
  resizeMaxWidth: 1100,
1015
- captureViewport: true,
1016
- padding: 8,
1182
+ captureViewport: false,
1183
+ padding: 0,
1017
1184
  maxScreenshots: maxImagePages,
1018
1185
  wheelDeltaY: imageWheelDeltaY,
1019
1186
  settleMs: 350,
1020
1187
  scrollMethod: "dom-anchor-fallback-input",
1021
1188
  stepTimeoutMs: 45000,
1022
1189
  totalTimeoutMs: 90000,
1023
- duplicateStopCount: 1,
1024
- skipDuplicateScreenshots: true,
1025
- composeForLlm: true,
1026
- llmPagesPerImage: 3,
1027
- llmResizeMaxWidth: 1100,
1028
- llmQuality: 72,
1029
- metadata: {
1030
- domain: "chat",
1031
- capture_mode: "scroll_sequence",
1190
+ duplicateStopCount: 1,
1191
+ skipDuplicateScreenshots: true,
1192
+ composeForLlm: true,
1193
+ llmPagesPerImage: 3,
1194
+ llmResizeMaxWidth: 1100,
1195
+ llmQuality: 72,
1196
+ stopBoundarySelector: CHAT_RESUME_IMAGE_STOP_BOUNDARY_SELECTOR,
1197
+ stopBoundaryTextPatterns: CHAT_RESUME_IMAGE_STOP_BOUNDARY_TEXT,
1198
+ stopBoundaryMaxProbeNodes: 360,
1199
+ stopBoundaryTopPadding: 10,
1200
+ stopBoundaryMinCaptureHeight: 180,
1201
+ metadata: {
1202
+ domain: "chat",
1203
+ capture_mode: "scroll_sequence",
1204
+ capture_scope: "resume_modal_clip",
1032
1205
  acquisition_reason: normalizedDetailSource === "image"
1033
1206
  ? "forced_image"
1034
1207
  : "network_miss_image_fallback",
@@ -1165,7 +1338,12 @@ export async function runChatWorkflow({
1165
1338
  skipped: Boolean(contentWait.skipped),
1166
1339
  reason: contentWait.reason || null,
1167
1340
  elapsed_ms: contentWait.elapsed_ms,
1168
- text_length: contentWait.text_length
1341
+ text_length: contentWait.text_length,
1342
+ timeout_ms: contentWait.timeout_ms ?? contentWait.dom_fallback_plan?.timeout_ms ?? null,
1343
+ configured_timeout_ms: contentWait.configured_timeout_ms
1344
+ ?? contentWait.dom_fallback_plan?.configured_timeout_ms
1345
+ ?? null,
1346
+ short_probe: Boolean(contentWait.short_probe)
1169
1347
  },
1170
1348
  parsed_network_profile_count: parsedNetworkProfileCount,
1171
1349
  image_evidence: summarizeImageEvidence(imageEvidence),