@reconcrap/boss-recommend-mcp 2.1.15 → 2.1.17

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/README.md CHANGED
@@ -391,6 +391,35 @@ config/screening-config.example.json
391
391
  - recommend / search / chat 图片简历 fallback 与主列表滚动都会在启用 `listScrollJitter` 时使用 coverage-safe scroll jitter:每次 wheel delta 在安全范围内变化,并保留截图重叠、重复检测、bottom-marker / stop-boundary 逻辑,实际 delta 和 settle 时间会写入 artifact metadata。
392
392
  - chat/recommend/search run 也兼容显式参数 `safe_pacing`、`batch_rest_enabled` 与 `human_behavior.restLevel`:run 参数优先于配置文件。AI harness/skill 启动每次 run 前必须让用户明确选择 `low/medium/high`,再把选择写入 `human_behavior.restLevel`。
393
393
 
394
+ ### 离线筛选延迟 benchmark
395
+
396
+ 可以用已保存的 recommend run JSON 和截图证据离线评估筛选策略,不打开 Boss、不重新点击候选人,也不改变 `简历来源`。benchmark 会读取每个 saved run 自己的 `result.screen_params.criteria`,缺失时再回退到 run context / instruction,避免用同一套 criteria 误测不同岗位。
397
+
398
+ ```bash
399
+ npm run benchmark:screening -- --dry-run
400
+ npm run benchmark:screening -- --max-candidates 20
401
+ ```
402
+
403
+ 默认会自动选择 `~/.boss-recommend-mcp/runs` 下最近 4 个仍有 `detail.image_evidence.llm_file_paths` 的 recommend run,并把输出写到 `.live-artifacts/screening-benchmark/<timestamp>/`。真实 LLM replay 会复用当前 `screening-config.json`;如需指定配置或 run:
404
+
405
+ ```bash
406
+ npm run benchmark:screening -- --config C:/Users/yaolin/.boss-recommend-mcp/screening-config.json --run mcp_recommend_mq92ltt5_3x9liodw
407
+ ```
408
+
409
+ 内置策略:
410
+
411
+ - `oracle_full_image_high`:完整图片筛选,`llmThinkingLevel=high`,作为 pass/fail 质量基准。
412
+ - `baseline_full_image_reasoning`:完整图片筛选,`llmThinkingLevel=low`,用于衡量当前低思考 baseline。
413
+ - `extract_then_reason`:先从图片抽取结构化简历事实,再用文本 reasoning 对每位候选人做最终判断;抽取/判断异常会升级到完整图片筛选。
414
+ - `extract_hard_gate_then_reason`:抽取后先做 cheap hard-fail gate;只有明确违反硬条件/排除项时直接 `passed=false`,否则继续文本 reasoning。
415
+ - `batch_extract_then_reason`:批量抽取多个候选人的结构化事实,再逐个文本 reasoning。
416
+ - `batch_extract_hard_gate_then_reason`:批量抽取后逐个执行 hard-fail gate,明显不合适的候选人不再进入后续 reasoning。
417
+ - `pipeline_simulation`:基于 saved timings 估算浏览器采集和 LLM 重叠后的理论下界;只用于判断是否值得后续改 workflow。
418
+
419
+ 为避免把“输出解释”本身的耗时算进策略,benchmark-native 的抽取/判断提示词不会要求模型输出筛选理由、summary 或 CoT;hard-fail gate 只要求 `{"hard_fail": true/false, "continue_reasoning": true/false, "uncertain": true/false}`,判断阶段只要求 `{"passed": true/false, "uncertain": true/false}`。
420
+
421
+ 输出文件包括 `benchmark-summary.json`、`benchmark-results.json`、`benchmark-results.csv`、`benchmark-disagreements.csv` 和 `benchmark-manifest.json`。所有非 oracle 策略都会和 `oracle_full_image_high` 对比 false negative / false positive / pass-rate drift,并用 `saved_non_llm_ms + benchmark_llm_ms` 计算 projected total time;hard-gate 策略会额外报告 `gate_ms` 和 `early_exit_count`;默认 eligibility 目标是平均 `<30000ms` 且 false negative 为 0。
422
+
394
423
  ## 常用命令
395
424
 
396
425
  npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示例展示源码模式(`node src/cli.js`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.1.15",
3
+ "version": "2.1.17",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -76,7 +76,8 @@
76
76
  "live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
77
77
  "live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
78
78
  "live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
79
- "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
79
+ "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js",
80
+ "benchmark:screening": "node scripts/benchmark-screening-strategies.js"
80
81
  },
81
82
  "files": [
82
83
  "bin",
package/src/chat-mcp.js CHANGED
@@ -756,8 +756,8 @@ function buildLegacyChatResult(snapshot) {
756
756
  };
757
757
  }
758
758
 
759
- function normalizeRunSnapshot(snapshot) {
760
- if (!snapshot) return null;
759
+ function normalizeRunSnapshot(snapshot) {
760
+ if (!snapshot) return null;
761
761
  const meta = getChatRunMeta(snapshot.runId);
762
762
  const artifacts = getChatRunArtifacts(snapshot.runId);
763
763
  const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
@@ -812,19 +812,61 @@ function normalizeRunSnapshot(snapshot) {
812
812
  },
813
813
  result: legacyResult,
814
814
  artifacts
815
- };
816
- }
817
-
818
- function persistChatRunSnapshot(snapshot, {
819
- persistActiveCheckpoint = false
820
- } = {}) {
821
- const normalized = normalizeRunSnapshot(snapshot);
822
- if (!normalized?.run_id) return normalized;
823
- const artifacts = getChatRunArtifacts(normalized.run_id);
824
- if (!artifacts) return normalized;
825
- if (persistActiveCheckpoint) {
826
- persistChatCheckpointSnapshot(normalized);
827
- }
815
+ };
816
+ }
817
+
818
+ function plainRecord(value) {
819
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
820
+ }
821
+
822
+ function mergePersistedChatControlRequest(normalized, existing) {
823
+ const control = {
824
+ ...(normalized?.control || {})
825
+ };
826
+ if (!normalized) return control;
827
+ const existingControl = plainRecord(existing?.control);
828
+ if (TERMINAL_STATUSES.has(normalized.state)) return control;
829
+ if (existingControl.cancel_requested === true) {
830
+ return {
831
+ ...control,
832
+ pause_requested: true,
833
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
834
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_boss_chat_run",
835
+ cancel_requested: true
836
+ };
837
+ }
838
+ if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
839
+ return {
840
+ ...control,
841
+ pause_requested: true,
842
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
843
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_boss_chat_run"
844
+ };
845
+ }
846
+ if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
847
+ return {
848
+ ...control,
849
+ pause_requested: false,
850
+ pause_requested_at: null,
851
+ pause_requested_by: null,
852
+ cancel_requested: false
853
+ };
854
+ }
855
+ return control;
856
+ }
857
+
858
+ function persistChatRunSnapshot(snapshot, {
859
+ persistActiveCheckpoint = false
860
+ } = {}) {
861
+ const normalized = normalizeRunSnapshot(snapshot);
862
+ if (!normalized?.run_id) return normalized;
863
+ const artifacts = getChatRunArtifacts(normalized.run_id);
864
+ if (!artifacts) return normalized;
865
+ const existing = readJsonFile(artifacts.run_state_path);
866
+ normalized.control = mergePersistedChatControlRequest(normalized, existing);
867
+ if (persistActiveCheckpoint) {
868
+ persistChatCheckpointSnapshot(normalized);
869
+ }
828
870
  const payload = {
829
871
  run_id: normalized.run_id,
830
872
  mode: normalized.mode,
@@ -52,3 +52,20 @@ export function assertGreetQuotaAvailable(source = "") {
52
52
  }
53
53
  return quota;
54
54
  }
55
+
56
+ export function describeGreetQuotaAfterSpend(source = "") {
57
+ const quota = normalizeGreetQuotaSource(source);
58
+ if (!quota.found || quota.numerator === null || quota.denominator === null) {
59
+ return {
60
+ ...quota,
61
+ remaining_after_spend: null,
62
+ exhausted_after_spend: false
63
+ };
64
+ }
65
+ const remaining = quota.denominator - quota.numerator;
66
+ return {
67
+ ...quota,
68
+ remaining_after_spend: remaining,
69
+ exhausted_after_spend: remaining < quota.numerator
70
+ };
71
+ }
@@ -50,6 +50,7 @@ const SEARCH_PARAM_ORDER = [
50
50
  "schools",
51
51
  "keyword",
52
52
  "filter_recent_viewed",
53
+ "skip_recent_colleague_contacted",
53
54
  "job",
54
55
  "start_from",
55
56
  "target_count",
@@ -60,7 +61,10 @@ const SCREEN_PARAM_ORDER = [
60
61
  "criteria",
61
62
  "target_count",
62
63
  "post_action",
63
- "max_greet_count"
64
+ "max_greet_count",
65
+ "skip_recent_colleague_contacted",
66
+ "colleague_contact_window_days",
67
+ "search_exchange_resume_filter_days"
64
68
  ];
65
69
 
66
70
  function normalizeText(value) {
@@ -5,6 +5,7 @@ import {
5
5
  getNodeBox,
6
6
  getOuterHTML,
7
7
  querySelectorAll,
8
+ scrollNodeIntoView,
8
9
  sleep
9
10
  } from "../../core/browser/index.js";
10
11
  import {
@@ -433,7 +434,21 @@ export async function waitForRecommendDetailActionControls(client, {
433
434
  export async function clickRecommendActionControl(client, control, {
434
435
  allowDisabled = false
435
436
  } = {}) {
436
- if (!control?.center) {
437
+ let clickCenter = control?.center || null;
438
+ let clickRect = control?.rect || null;
439
+ if (control?.node_id) {
440
+ try {
441
+ await scrollNodeIntoView(client, control.node_id);
442
+ await sleep(150);
443
+ const box = await getNodeBox(client, control.node_id);
444
+ clickCenter = box.center;
445
+ clickRect = box.rect;
446
+ } catch {
447
+ // Fall back to the discovered center below; callers still get a clear
448
+ // error if no usable click point exists.
449
+ }
450
+ }
451
+ if (!clickCenter) {
437
452
  throw new Error("Action control has no clickable center");
438
453
  }
439
454
  const greetQuota = control.kind === "greet"
@@ -442,7 +457,7 @@ export async function clickRecommendActionControl(client, control, {
442
457
  if (control.disabled && !allowDisabled) {
443
458
  throw new Error(`Action control is disabled: ${control.kind}`);
444
459
  }
445
- await clickPoint(client, control.center.x, control.center.y);
460
+ await clickPoint(client, clickCenter.x, clickCenter.y);
446
461
  return {
447
462
  clicked: true,
448
463
  kind: control.kind,
@@ -451,6 +466,7 @@ export async function clickRecommendActionControl(client, control, {
451
466
  selector: control.selector,
452
467
  root: control.root,
453
468
  node_id: control.node_id,
454
- center: control.center
469
+ center: clickCenter,
470
+ rect: clickRect
455
471
  };
456
472
  }
@@ -0,0 +1,333 @@
1
+ import {
2
+ clickNodeCenter,
3
+ getNodeBox,
4
+ getOuterHTML,
5
+ querySelectorAll,
6
+ sleep
7
+ } from "../../core/browser/index.js";
8
+ import { htmlToText } from "../../core/screening/index.js";
9
+
10
+ const COLLEAGUE_SECTION_SELECTOR = ".colleague-collaboration";
11
+ const COLLEAGUE_TAB_SELECTOR = ".colleague-collaboration .tab-hd";
12
+ const SELECTED_TAB_SELECTOR = ".colleague-collaboration .tab-hd .selected";
13
+ const SECTION_SELECTED_TAB_SELECTOR = ".tab-hd .selected";
14
+ const TAB_CANDIDATE_SELECTOR = ".tab-hd span, .tab-hd div, .tab-hd *";
15
+ const ROW_SELECTOR = ".colleague-collaboration .record-item.mate-log-item";
16
+ const ROW_CONTENT_SELECTOR = ".colleague-collaboration .record-item.mate-log-item .content";
17
+ const DETAIL_PANE_SELECTOR = ".resume-item-detail";
18
+
19
+ function normalizeText(value) {
20
+ return String(value || "").replace(/\s+/g, " ").trim();
21
+ }
22
+
23
+ function dateOnly(value) {
24
+ const date = value instanceof Date ? value : new Date(value);
25
+ if (Number.isNaN(date.getTime())) return null;
26
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
27
+ }
28
+
29
+ function daysBetween(left, right) {
30
+ const leftDate = dateOnly(left);
31
+ const rightDate = dateOnly(right);
32
+ if (!leftDate || !rightDate) return null;
33
+ return Math.floor((leftDate.getTime() - rightDate.getTime()) / 86400000);
34
+ }
35
+
36
+ function formatLocalDate(date) {
37
+ const parsed = dateOnly(date);
38
+ if (!parsed) return null;
39
+ const year = parsed.getFullYear();
40
+ const month = String(parsed.getMonth() + 1).padStart(2, "0");
41
+ const day = String(parsed.getDate()).padStart(2, "0");
42
+ return `${year}-${month}-${day}`;
43
+ }
44
+
45
+ function makeDate(year, month, day) {
46
+ const parsed = new Date(year, month - 1, day);
47
+ if (
48
+ parsed.getFullYear() !== year
49
+ || parsed.getMonth() !== month - 1
50
+ || parsed.getDate() !== day
51
+ ) {
52
+ return null;
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ export function parseColleagueContactDate(text, {
58
+ referenceDate = new Date()
59
+ } = {}) {
60
+ const raw = normalizeText(text);
61
+ if (!raw) return null;
62
+ const today = dateOnly(referenceDate) || dateOnly(new Date());
63
+ const relativeDays = raw.match(/(\d+)\s*天前/);
64
+ if (relativeDays) {
65
+ const days = Number.parseInt(relativeDays[1], 10);
66
+ if (Number.isFinite(days) && days >= 0) {
67
+ const date = new Date(today);
68
+ date.setDate(date.getDate() - days);
69
+ return date;
70
+ }
71
+ }
72
+ if (/今天/.test(raw)) return today;
73
+ if (/昨天/.test(raw)) {
74
+ const date = new Date(today);
75
+ date.setDate(date.getDate() - 1);
76
+ return date;
77
+ }
78
+ if (/前天/.test(raw)) {
79
+ const date = new Date(today);
80
+ date.setDate(date.getDate() - 2);
81
+ return date;
82
+ }
83
+
84
+ const full = raw.match(/(20\d{2})[.\-\/](\d{1,2})[.\-\/](\d{1,2})/);
85
+ if (full) {
86
+ return makeDate(
87
+ Number.parseInt(full[1], 10),
88
+ Number.parseInt(full[2], 10),
89
+ Number.parseInt(full[3], 10)
90
+ );
91
+ }
92
+
93
+ const partial = raw.match(/(?:^|\D)(\d{1,2})[.\-\/](\d{1,2})(?:\D|$)/);
94
+ if (partial) {
95
+ const reference = dateOnly(referenceDate) || new Date();
96
+ let date = makeDate(
97
+ reference.getFullYear(),
98
+ Number.parseInt(partial[1], 10),
99
+ Number.parseInt(partial[2], 10)
100
+ );
101
+ if (date && daysBetween(date, reference) > 7) {
102
+ date = makeDate(
103
+ reference.getFullYear() - 1,
104
+ Number.parseInt(partial[1], 10),
105
+ Number.parseInt(partial[2], 10)
106
+ );
107
+ }
108
+ return date;
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ export function isDateWithinWindow(date, {
115
+ referenceDate = new Date(),
116
+ windowDays = 14
117
+ } = {}) {
118
+ const diff = daysBetween(referenceDate, date);
119
+ return Number.isFinite(diff) && diff >= 0 && diff <= windowDays;
120
+ }
121
+
122
+ async function textForNode(client, nodeId) {
123
+ return htmlToText(await getOuterHTML(client, nodeId));
124
+ }
125
+
126
+ async function queryAcrossRoots(client, roots, selector) {
127
+ const matches = [];
128
+ for (const root of roots || []) {
129
+ if (!root?.nodeId) continue;
130
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector).catch(() => []);
131
+ for (const nodeId of nodeIds) {
132
+ matches.push({
133
+ root: root.name,
134
+ root_node_id: root.nodeId,
135
+ selector,
136
+ node_id: nodeId
137
+ });
138
+ }
139
+ }
140
+ return matches;
141
+ }
142
+
143
+ function tabIsColleague(text) {
144
+ return /同事沟通进度/.test(normalizeText(text));
145
+ }
146
+
147
+ async function ensureColleagueTabSelected(client, sectionNodeId) {
148
+ const selectedIds = await querySelectorAll(client, sectionNodeId, SECTION_SELECTED_TAB_SELECTOR).catch(() => []);
149
+ for (const nodeId of selectedIds) {
150
+ const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
151
+ if (tabIsColleague(text)) {
152
+ return {
153
+ selected: true,
154
+ changed: false,
155
+ selected_text: text
156
+ };
157
+ }
158
+ }
159
+
160
+ const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR).catch(() => []);
161
+ for (const nodeId of candidateIds) {
162
+ const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
163
+ if (!tabIsColleague(text)) continue;
164
+ const box = await clickNodeCenter(client, nodeId, { scrollIntoView: true });
165
+ await sleep(500);
166
+ return {
167
+ selected: true,
168
+ changed: true,
169
+ selected_text: text,
170
+ click_box: {
171
+ rect: box.rect,
172
+ center: box.center
173
+ }
174
+ };
175
+ }
176
+
177
+ return {
178
+ selected: false,
179
+ changed: false,
180
+ selected_text: selectedIds.length
181
+ ? normalizeText(await textForNode(client, selectedIds[0]).catch(() => ""))
182
+ : ""
183
+ };
184
+ }
185
+
186
+ async function readContactRows(client, roots) {
187
+ const rowMatches = await queryAcrossRoots(client, roots, ROW_CONTENT_SELECTOR);
188
+ const fallbackMatches = rowMatches.length ? [] : await queryAcrossRoots(client, roots, ROW_SELECTOR);
189
+ const matches = rowMatches.length ? rowMatches : fallbackMatches;
190
+ const rows = [];
191
+ const seen = new Set();
192
+ for (const match of matches) {
193
+ const text = normalizeText(await textForNode(client, match.node_id).catch(() => ""));
194
+ if (!text || seen.has(text)) continue;
195
+ seen.add(text);
196
+ rows.push({
197
+ text,
198
+ root: match.root,
199
+ selector: match.selector,
200
+ node_id: match.node_id
201
+ });
202
+ }
203
+ return rows;
204
+ }
205
+
206
+ async function scrollDetailPaneForRows(client, roots, sectionNodeId, {
207
+ maxScrolls = 4,
208
+ settleMs = 350
209
+ } = {}) {
210
+ const detailPanes = await queryAcrossRoots(client, roots, DETAIL_PANE_SELECTOR);
211
+ const targetNodeId = detailPanes[0]?.node_id || sectionNodeId;
212
+ let box = null;
213
+ try {
214
+ box = await getNodeBox(client, targetNodeId);
215
+ } catch {
216
+ try {
217
+ box = await getNodeBox(client, sectionNodeId);
218
+ } catch {
219
+ return { scrolls: 0, reason: "scroll_target_box_unavailable" };
220
+ }
221
+ }
222
+ let scrolls = 0;
223
+ for (let index = 0; index < maxScrolls; index += 1) {
224
+ await client.Input.dispatchMouseEvent({
225
+ type: "mouseWheel",
226
+ x: box.center.x,
227
+ y: box.center.y,
228
+ deltaY: 680,
229
+ deltaX: 0
230
+ });
231
+ scrolls += 1;
232
+ await sleep(settleMs);
233
+ }
234
+ return {
235
+ scrolls,
236
+ target_selector: detailPanes[0]?.selector || COLLEAGUE_SECTION_SELECTOR
237
+ };
238
+ }
239
+
240
+ async function waitForColleagueSections(client, roots, {
241
+ timeoutMs = 1000,
242
+ intervalMs = 150
243
+ } = {}) {
244
+ const started = Date.now();
245
+ let sections = [];
246
+ do {
247
+ sections = await queryAcrossRoots(client, roots, COLLEAGUE_SECTION_SELECTOR);
248
+ if (sections.length) return sections;
249
+ if (Date.now() - started >= timeoutMs) break;
250
+ await sleep(intervalMs);
251
+ } while (Date.now() - started <= timeoutMs);
252
+ return sections;
253
+ }
254
+
255
+ export async function inspectRecentColleagueContact(client, detailState, {
256
+ referenceDate = new Date(),
257
+ windowDays = 14,
258
+ scroll = true,
259
+ sectionWaitMs = 1000,
260
+ sectionPollMs = 150
261
+ } = {}) {
262
+ const roots = detailState?.roots || [];
263
+ const sections = await waitForColleagueSections(client, roots, {
264
+ timeoutMs: sectionWaitMs,
265
+ intervalMs: sectionPollMs
266
+ });
267
+ if (!sections.length) {
268
+ return {
269
+ checked: true,
270
+ panel_found: false,
271
+ recent: false,
272
+ reason: "panel_missing",
273
+ window_days: windowDays,
274
+ rows: []
275
+ };
276
+ }
277
+
278
+ const section = sections[0];
279
+ const tabHeader = await queryAcrossRoots(client, roots, COLLEAGUE_TAB_SELECTOR);
280
+ const tab = await ensureColleagueTabSelected(client, section.node_id);
281
+ if (!tab.selected) {
282
+ return {
283
+ checked: true,
284
+ panel_found: true,
285
+ recent: false,
286
+ reason: "colleague_tab_unavailable",
287
+ window_days: windowDays,
288
+ section_root: section.root,
289
+ tab_header_found: tabHeader.length > 0,
290
+ selected_tab_text: tab.selected_text,
291
+ rows: []
292
+ };
293
+ }
294
+
295
+ let rows = await readContactRows(client, roots);
296
+ let scroll_probe = null;
297
+ if (scroll) {
298
+ scroll_probe = await scrollDetailPaneForRows(client, roots, section.node_id);
299
+ const afterScrollRows = await readContactRows(client, roots);
300
+ const byText = new Map(rows.map((row) => [row.text, row]));
301
+ for (const row of afterScrollRows) {
302
+ if (!byText.has(row.text)) byText.set(row.text, row);
303
+ }
304
+ rows = Array.from(byText.values());
305
+ }
306
+
307
+ const parsedRows = rows.map((row) => {
308
+ const parsedDate = parseColleagueContactDate(row.text, { referenceDate });
309
+ return {
310
+ ...row,
311
+ parsed_date: parsedDate ? formatLocalDate(parsedDate) : null,
312
+ within_window: parsedDate
313
+ ? isDateWithinWindow(parsedDate, { referenceDate, windowDays })
314
+ : false
315
+ };
316
+ });
317
+ const matched = parsedRows.find((row) => row.within_window) || null;
318
+ return {
319
+ checked: true,
320
+ panel_found: true,
321
+ recent: Boolean(matched),
322
+ reason: matched ? "recent_colleague_contact_found" : "no_recent_colleague_contact",
323
+ window_days: windowDays,
324
+ section_root: section.root,
325
+ tab_header_found: tabHeader.length > 0,
326
+ selected_tab_text: tab.selected_text,
327
+ tab_changed: tab.changed,
328
+ matched_row: matched,
329
+ row_count: parsedRows.length,
330
+ rows: parsedRows,
331
+ scroll_probe
332
+ };
333
+ }
@@ -6,5 +6,6 @@ export * from "./detail.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./jobs.js";
8
8
  export * from "./scopes.js";
9
+ export * from "./colleague-contact.js";
9
10
  export * from "./refresh.js";
10
11
  export * from "./run-service.js";