@reconcrap/boss-recommend-mcp 1.2.6 → 1.2.7

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
@@ -14,6 +14,12 @@ MCP 工具:
14
14
  - `cancel_recommend_pipeline_run`(取消运行中任务)
15
15
  - `pause_recommend_pipeline_run`(请求暂停 run;会在当前候选人处理完成后进入 paused)
16
16
  - `resume_recommend_pipeline_run`(继续 paused run;沿用同 run_id 与同 CSV)
17
+ - `run_recommend_self_heal`(手动运维工具;扫描 Boss recommend 的 selector / network 规则漂移,并在确认后应用高置信度修复)
18
+ - `validation_profile=safe`:只做非破坏性扫描与被动 network 观察
19
+ - `validation_profile=full`:会主动打开候选人详情,并执行收藏往返校验与一次打招呼校验;若完整交互没跑通,会明确返回验证异常而不是静默跳过
20
+ - 扫描会主动覆盖 recommend/latest/featured 三个 tab 的详情链路(详情打开、详情内关键 selector、popup 关闭)
21
+ - 搜索链路 selector 会在状态触发后验证:职位下拉、职位搜索输入、职位 label、筛选面板、筛选分组(school/degree/gender/recentNotView)、筛选滚动容器与筛选项激活态
22
+ - 对关闭弹层相关 selector,会同时验证 close 按钮与 fallback close 候选 selector
17
23
 
18
24
  状态机:
19
25
 
@@ -52,6 +58,7 @@ MCP 工具:
52
58
  - 不会对每位候选人重复确认
53
59
  - 推荐页详情处理完成后,会强制关闭详情页并确认已关闭
54
60
  - 简历提取采用“分段滚动截图 + 拼成长图”的方式,再交给多模态模型判断
61
+ - 提供显式运维自愈工具:只在手动调用 `run_recommend_self_heal` 时运行,不会接入正常 run / doctor / preflight 自动链路
55
62
  - 运行前会自动做依赖体检(Node.js、Python、Pillow、`chrome-remote-interface`、`ws`),缺失时会在 `doctor` 与流水线失败诊断中明确提示
56
63
  - 若 preflight 失败,返回 `diagnostics.recovery`(含有序修复步骤与 `agent_prompt`),可直接交给 AI agent 自动按顺序安装依赖
57
64
  - 不依赖 PowerShell;Windows / macOS 均可运行(命令提示会按平台给出)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -25,6 +25,7 @@
25
25
  "test:run-state": "node src/test-run-state.js",
26
26
  "test:runtime": "node src/test-adapters-runtime.js",
27
27
  "test:async": "node src/test-index-async.js",
28
+ "test:self-heal": "node src/test-self-heal.js",
28
29
  "test:search-job": "node vendor/boss-recommend-search-cli/src/test-job-selection.js",
29
30
  "test:screen-runtime": "node vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs"
30
31
  },
package/src/adapters.js CHANGED
@@ -1,9 +1,13 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { spawn, spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import CDP from "chrome-remote-interface";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import CDP from "chrome-remote-interface";
7
+ import {
8
+ buildFirstSelectorLookupExpression,
9
+ getRecommendSelectorRule
10
+ } from "./recommend-healing-config.js";
7
11
 
8
12
  const currentFilePath = fileURLToPath(import.meta.url);
9
13
  const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
@@ -23,6 +27,44 @@ const PAGE_SCOPE_TO_TAB_STATUS = {
23
27
  latest: "1",
24
28
  featured: "3"
25
29
  };
30
+ const RECOMMEND_IFRAME_SELECTORS = getRecommendSelectorRule(
31
+ ["top", "recommend_iframe"],
32
+ ['iframe[name="recommendFrame"]', 'iframe[src*="/web/frame/recommend/"]', "iframe"]
33
+ );
34
+ const RECOMMEND_TAB_SELECTORS = getRecommendSelectorRule(
35
+ ["frame", "tab_items"],
36
+ ['li.tab-item[data-status]', 'li[data-status][class*="tab"]']
37
+ );
38
+ const RECOMMEND_CARD_SELECTORS = getRecommendSelectorRule(["frame", "recommend_cards"], ['ul.card-list > li.card-item']);
39
+ const FEATURED_CARD_SELECTORS = getRecommendSelectorRule(["frame", "featured_cards"], ['li.geek-info-card']);
40
+ const LATEST_CARD_SELECTORS = getRecommendSelectorRule(["frame", "latest_cards"], ['.candidate-card-wrap .card-inner[data-geek]', '.candidate-card-wrap [data-geek]']);
41
+ const DETAIL_POPUP_SELECTORS = getRecommendSelectorRule(
42
+ ["detail", "popup"],
43
+ [
44
+ ".dialog-wrap.active",
45
+ ".boss-popup__wrapper",
46
+ ".boss-popup_wrapper",
47
+ ".boss-dialog_wrapper",
48
+ ".boss-dialog",
49
+ ".resume-item-detail",
50
+ ".geek-detail-modal",
51
+ '[class*="popup"][class*="wrapper"]',
52
+ '[class*="dialog"][class*="wrapper"]'
53
+ ]
54
+ );
55
+ const DETAIL_RESUME_IFRAME_SELECTORS = getRecommendSelectorRule(
56
+ ["detail", "resume_iframe"],
57
+ ['iframe[src*="/web/frame/c-resume/"]', 'iframe[name*="resume"]']
58
+ );
59
+ const REFRESH_FINISHED_WRAP_SELECTORS = getRecommendSelectorRule(["frame", "refresh_finished_wrap"], [".finished-wrap"]);
60
+ const REFRESH_BUTTON_SELECTORS = getRecommendSelectorRule(
61
+ ["frame", "refresh_button"],
62
+ [".finished-wrap .btn.btn-refresh", ".finished-wrap .btn-refresh", ".no-data-refresh .btn-refresh"]
63
+ );
64
+
65
+ function buildRecommendIframeLookupExpression(rootExpr = "document") {
66
+ return buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS, rootExpr);
67
+ }
26
68
 
27
69
  function getCodexHome() {
28
70
  return process.env.CODEX_HOME
@@ -1575,9 +1617,7 @@ function buildRecommendIframeProbeExpression() {
1575
1617
  title
1576
1618
  };
1577
1619
  }
1578
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1579
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1580
- || document.querySelector('iframe');
1620
+ const frame = ${buildRecommendIframeLookupExpression()};
1581
1621
  const frameUrl = (() => {
1582
1622
  try {
1583
1623
  return String(frame?.contentWindow?.location?.href || frame?.src || "");
@@ -1798,15 +1838,16 @@ async function evaluateCdpExpression(client, expression) {
1798
1838
 
1799
1839
  function buildRecommendTabStateExpression() {
1800
1840
  return `(() => {
1801
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1802
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1803
- || document.querySelector('iframe');
1841
+ const frame = ${buildRecommendIframeLookupExpression()};
1804
1842
  if (!frame || !frame.contentDocument) {
1805
1843
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1806
1844
  }
1807
1845
  const doc = frame.contentDocument;
1808
1846
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1809
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]')).map((node) => {
1847
+ const tabs = Array.from(new Set(${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
1848
+ .flatMap((selector) => {
1849
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1850
+ }))).map((node) => {
1810
1851
  const status = normalize(node.getAttribute('data-status'));
1811
1852
  const className = normalize(node.className);
1812
1853
  const active = (
@@ -1823,9 +1864,18 @@ function buildRecommendTabStateExpression() {
1823
1864
  };
1824
1865
  });
1825
1866
  const activeTab = tabs.find((item) => item.active && item.status) || null;
1826
- const featuredCount = doc.querySelectorAll('li.geek-info-card').length;
1827
- const recommendCount = doc.querySelectorAll('ul.card-list > li.card-item').length;
1828
- const latestCount = doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]').length;
1867
+ const featuredCount = ${JSON.stringify(FEATURED_CARD_SELECTORS)}
1868
+ .reduce((sum, selector) => {
1869
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1870
+ }, 0);
1871
+ const recommendCount = ${JSON.stringify(RECOMMEND_CARD_SELECTORS)}
1872
+ .reduce((sum, selector) => {
1873
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1874
+ }, 0);
1875
+ const latestCount = ${JSON.stringify(LATEST_CARD_SELECTORS)}
1876
+ .reduce((sum, selector) => {
1877
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1878
+ }, 0);
1829
1879
  let inferredStatus = activeTab?.status || null;
1830
1880
  if (!inferredStatus) {
1831
1881
  if (featuredCount > 0 && recommendCount === 0 && latestCount === 0) inferredStatus = '3';
@@ -1847,15 +1897,16 @@ function buildRecommendTabStateExpression() {
1847
1897
 
1848
1898
  function buildRecommendTabSwitchExpression(targetStatus) {
1849
1899
  return `((targetStatus) => {
1850
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1851
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1852
- || document.querySelector('iframe');
1900
+ const frame = ${buildRecommendIframeLookupExpression()};
1853
1901
  if (!frame || !frame.contentDocument) {
1854
1902
  return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
1855
1903
  }
1856
1904
  const doc = frame.contentDocument;
1857
1905
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1858
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
1906
+ const tabs = Array.from(new Set(${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
1907
+ .flatMap((selector) => {
1908
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1909
+ })));
1859
1910
  const target = tabs.find((node) => normalize(node.getAttribute('data-status')) === String(targetStatus)) || null;
1860
1911
  if (!target) {
1861
1912
  return { ok: false, state: 'TAB_NOT_FOUND', target_status: String(targetStatus) };
@@ -1883,19 +1934,7 @@ function buildRecommendTabSwitchExpression(targetStatus) {
1883
1934
 
1884
1935
  function buildRecommendDetailStateExpression() {
1885
1936
  return `(() => {
1886
- const selectors = [
1887
- '.dialog-wrap.active',
1888
- '.boss-popup__wrapper',
1889
- '.boss-popup_wrapper',
1890
- '.boss-dialog_wrapper',
1891
- '.boss-dialog',
1892
- '.resume-item-detail',
1893
- '.geek-detail-modal',
1894
- 'iframe[src*="/web/frame/c-resume/"]',
1895
- 'iframe[name*="resume"]',
1896
- '[class*="popup"][class*="wrapper"]',
1897
- '[class*="dialog"][class*="wrapper"]'
1898
- ];
1937
+ const selectors = ${JSON.stringify([...DETAIL_POPUP_SELECTORS, ...DETAIL_RESUME_IFRAME_SELECTORS])};
1899
1938
  const isVisible = (node) => {
1900
1939
  if (!node || !node.getBoundingClientRect) return false;
1901
1940
  const rect = node.getBoundingClientRect();
@@ -1929,9 +1968,7 @@ function buildRecommendDetailStateExpression() {
1929
1968
  const topDetail = findVisibleDetail(document, 'top');
1930
1969
  if (topDetail) return topDetail;
1931
1970
 
1932
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1933
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1934
- || null;
1971
+ const frame = ${buildRecommendIframeLookupExpression()} || null;
1935
1972
  if (!frame || !frame.contentDocument) {
1936
1973
  return { ok: true, open: false, reason: 'NO_RECOMMEND_IFRAME' };
1937
1974
  }
@@ -2195,14 +2232,12 @@ export async function switchRecommendTab(workspaceRoot, options = {}) {
2195
2232
  }
2196
2233
  }
2197
2234
 
2198
- function buildRecommendRefreshStateExpression() {
2199
- return `(() => {
2200
- const frame = document.querySelector('iframe[name="recommendFrame"]')
2201
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
2202
- || document.querySelector('iframe');
2203
- if (!frame || !frame.contentDocument) {
2204
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2205
- }
2235
+ function buildRecommendRefreshStateExpression() {
2236
+ return `(() => {
2237
+ const frame = ${buildRecommendIframeLookupExpression()};
2238
+ if (!frame || !frame.contentDocument) {
2239
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2240
+ }
2206
2241
  const doc = frame.contentDocument;
2207
2242
  const isVisible = (el) => {
2208
2243
  if (!el) return false;
@@ -2215,13 +2250,29 @@ function buildRecommendRefreshStateExpression() {
2215
2250
  const rect = el.getBoundingClientRect();
2216
2251
  return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
2217
2252
  };
2218
- const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
2219
- const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
2220
- .find((el) => isVisible(el)) || null;
2221
- const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
2253
+ const finishedWrap = ${JSON.stringify(REFRESH_FINISHED_WRAP_SELECTORS)}
2254
+ .flatMap((selector) => {
2255
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2256
+ })
2257
+ .find((el) => isVisible(el)) || null;
2258
+ const refreshButton = ${JSON.stringify(REFRESH_BUTTON_SELECTORS)}
2259
+ .flatMap((selector) => {
2260
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2261
+ })
2262
+ .find((el) => isVisible(el)) || null;
2263
+ const cards = ${JSON.stringify(RECOMMEND_CARD_SELECTORS)}
2264
+ .flatMap((selector) => {
2265
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2266
+ });
2222
2267
  const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
2223
- const latestCards = Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'));
2224
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
2268
+ const latestCards = ${JSON.stringify(LATEST_CARD_SELECTORS)}
2269
+ .flatMap((selector) => {
2270
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2271
+ });
2272
+ const tabs = ${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
2273
+ .flatMap((selector) => {
2274
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2275
+ });
2225
2276
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
2226
2277
  const activeStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
2227
2278
  const inferredStatus = activeStatus
@@ -2247,14 +2298,12 @@ function buildRecommendRefreshStateExpression() {
2247
2298
  })()`;
2248
2299
  }
2249
2300
 
2250
- function buildRecommendRefreshClickExpression() {
2251
- return `(() => {
2252
- const frame = document.querySelector('iframe[name="recommendFrame"]')
2253
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
2254
- || document.querySelector('iframe');
2255
- if (!frame || !frame.contentDocument) {
2256
- return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
2257
- }
2301
+ function buildRecommendRefreshClickExpression() {
2302
+ return `(() => {
2303
+ const frame = ${buildRecommendIframeLookupExpression()};
2304
+ if (!frame || !frame.contentDocument) {
2305
+ return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
2306
+ }
2258
2307
  const doc = frame.contentDocument;
2259
2308
  const isVisible = (el) => {
2260
2309
  if (!el) return false;
@@ -2267,8 +2316,11 @@ function buildRecommendRefreshClickExpression() {
2267
2316
  const rect = el.getBoundingClientRect();
2268
2317
  return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
2269
2318
  };
2270
- const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
2271
- .find((el) => isVisible(el)) || null;
2319
+ const refreshButton = ${JSON.stringify(REFRESH_BUTTON_SELECTORS)}
2320
+ .flatMap((selector) => {
2321
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2322
+ })
2323
+ .find((el) => isVisible(el)) || null;
2272
2324
  if (!refreshButton) {
2273
2325
  return { ok: false, state: 'REFRESH_BUTTON_NOT_FOUND' };
2274
2326
  }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  runRecommendCalibration
10
10
  } from "./adapters.js";
11
11
  import { runRecommendPipeline } from "./pipeline.js";
12
+ import { runRecommendSelfHeal } from "./self-heal.js";
12
13
  import {
13
14
  RUN_MODE_ASYNC,
14
15
  RUN_STAGE_PREFLIGHT,
@@ -39,6 +40,7 @@ const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
39
40
  const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
40
41
  const TOOL_RUN_FEATURED_CALIBRATION = "run_featured_calibration";
41
42
  const TOOL_GET_FEATURED_CALIBRATION_STATUS = "get_featured_calibration_status";
43
+ const TOOL_RUN_RECOMMEND_SELF_HEAL = "run_recommend_self_heal";
42
44
 
43
45
  const SERVER_NAME = "boss-recommend-mcp";
44
46
  const FRAMING_UNKNOWN = "unknown";
@@ -49,6 +51,7 @@ const DETACHED_WORKER_RUN_ID_FLAG = "--run-id";
49
51
  const DETACHED_WORKER_RESUME_FLAG = "--resume";
50
52
 
51
53
  let runPipelineImpl = runRecommendPipeline;
54
+ let runSelfHealImpl = runRecommendSelfHeal;
52
55
  let spawnProcessImpl = spawn;
53
56
  const TERMINAL_RUN_STATES = new Set([RUN_STATE_COMPLETED, RUN_STATE_FAILED, RUN_STATE_CANCELED]);
54
57
 
@@ -321,6 +324,43 @@ function createRunFeaturedCalibrationInputSchema() {
321
324
  };
322
325
  }
323
326
 
327
+ function createRunRecommendSelfHealInputSchema() {
328
+ return {
329
+ type: "object",
330
+ properties: {
331
+ mode: {
332
+ type: "string",
333
+ enum: ["scan", "apply"],
334
+ description: "scan=扫描漂移;apply=按 repair_session_id 应用高置信度修复"
335
+ },
336
+ scope: {
337
+ type: "string",
338
+ enum: ["full", "search_screen", "selectors_only"],
339
+ description: "扫描范围,默认 full"
340
+ },
341
+ validation_profile: {
342
+ type: "string",
343
+ enum: ["safe", "full"],
344
+ description: "校验强度,默认 full"
345
+ },
346
+ port: {
347
+ type: "integer",
348
+ minimum: 1,
349
+ description: "可选,Boss Chrome 远程调试端口"
350
+ },
351
+ repair_session_id: {
352
+ type: "string",
353
+ description: "apply 模式必填,来自 scan 返回值"
354
+ },
355
+ confirm_apply: {
356
+ type: "boolean",
357
+ description: "apply 模式必填,必须显式传 true"
358
+ }
359
+ },
360
+ additionalProperties: false
361
+ };
362
+ }
363
+
324
364
  function createToolsSchema() {
325
365
  return [
326
366
  {
@@ -389,6 +429,11 @@ function createToolsSchema() {
389
429
  properties: {},
390
430
  additionalProperties: false
391
431
  }
432
+ },
433
+ {
434
+ name: TOOL_RUN_RECOMMEND_SELF_HEAL,
435
+ description: "手动运维自愈工具:扫描 Boss recommend 相关 selector / network 规则漂移,并在确认后应用高置信度修复。",
436
+ inputSchema: createRunRecommendSelfHealInputSchema()
392
437
  }
393
438
  ];
394
439
  }
@@ -448,6 +493,42 @@ function validateRunFeaturedCalibrationArgs(args) {
448
493
  return null;
449
494
  }
450
495
 
496
+ function validateRunRecommendSelfHealArgs(args) {
497
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
498
+ return "arguments must be an object";
499
+ }
500
+
501
+ if (Object.prototype.hasOwnProperty.call(args, "mode")) {
502
+ const mode = normalizeText(args.mode).toLowerCase();
503
+ if (!["scan", "apply"].includes(mode)) {
504
+ return "mode must be one of: scan, apply";
505
+ }
506
+ }
507
+
508
+ if (Object.prototype.hasOwnProperty.call(args, "scope")) {
509
+ const scope = normalizeText(args.scope).toLowerCase();
510
+ if (!["full", "search_screen", "selectors_only"].includes(scope)) {
511
+ return "scope must be one of: full, search_screen, selectors_only";
512
+ }
513
+ }
514
+
515
+ if (Object.prototype.hasOwnProperty.call(args, "validation_profile")) {
516
+ const profile = normalizeText(args.validation_profile).toLowerCase();
517
+ if (!["safe", "full"].includes(profile)) {
518
+ return "validation_profile must be one of: safe, full";
519
+ }
520
+ }
521
+
522
+ if (Object.prototype.hasOwnProperty.call(args, "port")) {
523
+ const port = Number.parseInt(String(args.port), 10);
524
+ if (!Number.isFinite(port) || port <= 0) {
525
+ return "port must be a positive integer";
526
+ }
527
+ }
528
+
529
+ return null;
530
+ }
531
+
451
532
  function getLastOutputLine(text) {
452
533
  const lines = String(text || "")
453
534
  .split(/\r?\n/)
@@ -1340,6 +1421,10 @@ async function handleRunFeaturedCalibrationTool({ workspaceRoot, args }) {
1340
1421
  };
1341
1422
  }
1342
1423
 
1424
+ async function handleRunRecommendSelfHealTool({ workspaceRoot, args }) {
1425
+ return runSelfHealImpl({ workspaceRoot, args });
1426
+ }
1427
+
1343
1428
  async function handleRequest(message, workspaceRoot) {
1344
1429
  if (!message || message.jsonrpc !== "2.0") {
1345
1430
  return createJsonRpcError(null, -32600, "Invalid JSON-RPC request");
@@ -1396,6 +1481,13 @@ async function handleRequest(message, workspaceRoot) {
1396
1481
  }
1397
1482
  }
1398
1483
 
1484
+ if (toolName === TOOL_RUN_RECOMMEND_SELF_HEAL) {
1485
+ const inputError = validateRunRecommendSelfHealArgs(args);
1486
+ if (inputError) {
1487
+ return createJsonRpcError(id, -32602, inputError);
1488
+ }
1489
+ }
1490
+
1399
1491
  if ([TOOL_GET_RUN, TOOL_CANCEL_RUN, TOOL_PAUSE_RUN, TOOL_RESUME_RUN].includes(toolName)) {
1400
1492
  if (!args || typeof args.run_id !== "string" || !normalizeText(args.run_id)) {
1401
1493
  return createJsonRpcError(id, -32602, "run_id is required and must be a string");
@@ -1418,6 +1510,8 @@ async function handleRequest(message, workspaceRoot) {
1418
1510
  payload = handleGetFeaturedCalibrationStatusTool(workspaceRoot);
1419
1511
  } else if (toolName === TOOL_RUN_FEATURED_CALIBRATION) {
1420
1512
  payload = await handleRunFeaturedCalibrationTool({ workspaceRoot, args });
1513
+ } else if (toolName === TOOL_RUN_RECOMMEND_SELF_HEAL) {
1514
+ payload = await handleRunRecommendSelfHealTool({ workspaceRoot, args });
1421
1515
  } else {
1422
1516
  return createJsonRpcError(id, -32602, `Unknown tool: ${toolName || ""}`);
1423
1517
  }
@@ -1553,6 +1647,9 @@ export const __testables = {
1553
1647
  },
1554
1648
  setRunPipelineImplForTests(nextImpl) {
1555
1649
  runPipelineImpl = typeof nextImpl === "function" ? nextImpl : runRecommendPipeline;
1650
+ },
1651
+ setRunSelfHealImplForTests(nextImpl) {
1652
+ runSelfHealImpl = typeof nextImpl === "function" ? nextImpl : runRecommendSelfHeal;
1556
1653
  }
1557
1654
  };
1558
1655
 
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const currentFilePath = fileURLToPath(import.meta.url);
9
+ const defaultRulesPath = path.join(path.dirname(currentFilePath), "recommend-healing-rules.json");
10
+
11
+ let cachedRulesPath = null;
12
+ let cachedRulesMtime = null;
13
+ let cachedRules = null;
14
+
15
+ function clone(value) {
16
+ return JSON.parse(JSON.stringify(value));
17
+ }
18
+
19
+ function normalizePathLike(value) {
20
+ return String(value || "").trim();
21
+ }
22
+
23
+ function getNestedValue(root, pathParts = []) {
24
+ let current = root;
25
+ for (const part of pathParts) {
26
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
27
+ return undefined;
28
+ }
29
+ current = current[part];
30
+ }
31
+ return current;
32
+ }
33
+
34
+ export function getRecommendHealingRulesPath() {
35
+ const fromEnv = normalizePathLike(process.env.BOSS_RECOMMEND_HEALING_RULES_FILE);
36
+ return fromEnv ? path.resolve(fromEnv) : defaultRulesPath;
37
+ }
38
+
39
+ export function loadRecommendHealingRules(options = {}) {
40
+ const fresh = options.fresh === true;
41
+ const rulesPath = getRecommendHealingRulesPath();
42
+ const stats = fs.statSync(rulesPath);
43
+ if (
44
+ !fresh
45
+ && cachedRules
46
+ && cachedRulesPath === rulesPath
47
+ && cachedRulesMtime === Number(stats.mtimeMs)
48
+ ) {
49
+ return clone(cachedRules);
50
+ }
51
+ const nextRules = require(rulesPath);
52
+ cachedRulesPath = rulesPath;
53
+ cachedRulesMtime = Number(stats.mtimeMs);
54
+ cachedRules = clone(nextRules);
55
+ return clone(cachedRules);
56
+ }
57
+
58
+ export function saveRecommendHealingRules(nextRules) {
59
+ const rulesPath = getRecommendHealingRulesPath();
60
+ const serialized = `${JSON.stringify(nextRules, null, 2)}\n`;
61
+ fs.writeFileSync(rulesPath, serialized, "utf8");
62
+ cachedRulesPath = rulesPath;
63
+ cachedRulesMtime = Number(fs.statSync(rulesPath).mtimeMs);
64
+ cachedRules = clone(nextRules);
65
+ return rulesPath;
66
+ }
67
+
68
+ export function getRecommendSelectorRule(pathParts = [], fallback = []) {
69
+ const value = getNestedValue(loadRecommendHealingRules(), ["selectors", ...pathParts]);
70
+ return Array.isArray(value) && value.length > 0 ? value.map((item) => String(item)) : fallback.slice();
71
+ }
72
+
73
+ export function getRecommendNetworkRule(pathParts = [], fallback = null) {
74
+ const value = getNestedValue(loadRecommendHealingRules(), ["network", ...pathParts]);
75
+ if (Array.isArray(value)) return value.map((item) => String(item));
76
+ if (value && typeof value === "object") return clone(value);
77
+ if (typeof value === "string") return value;
78
+ if (Array.isArray(fallback)) return fallback.slice();
79
+ if (fallback && typeof fallback === "object") return clone(fallback);
80
+ return fallback;
81
+ }
82
+
83
+ export function compileRegexList(patterns = []) {
84
+ return (Array.isArray(patterns) ? patterns : [])
85
+ .map((pattern) => {
86
+ try {
87
+ return new RegExp(String(pattern), "i");
88
+ } catch {
89
+ return null;
90
+ }
91
+ })
92
+ .filter(Boolean);
93
+ }
94
+
95
+ export function findFirstMatchingPattern(value, patterns = []) {
96
+ const text = String(value || "");
97
+ for (const pattern of compileRegexList(patterns)) {
98
+ if (pattern.test(text)) return pattern.source;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export function matchesAnyPattern(value, patterns = []) {
104
+ return Boolean(findFirstMatchingPattern(value, patterns));
105
+ }
106
+
107
+ export function buildFirstSelectorLookupExpression(selectors = [], rootExpr = "document") {
108
+ return `(() => {
109
+ const selectors = ${JSON.stringify(selectors)};
110
+ for (const selector of selectors) {
111
+ try {
112
+ const node = ${rootExpr}.querySelector(selector);
113
+ if (node) return node;
114
+ } catch {}
115
+ }
116
+ return null;
117
+ })()`;
118
+ }
119
+
120
+ export function buildSelectorCollectionExpression(selectors = [], rootExpr = "document") {
121
+ return `(() => {
122
+ const selectors = ${JSON.stringify(selectors)};
123
+ const nodes = [];
124
+ for (const selector of selectors) {
125
+ try {
126
+ nodes.push(...Array.from(${rootExpr}.querySelectorAll(selector)));
127
+ } catch {}
128
+ }
129
+ return Array.from(new Set(nodes));
130
+ })()`;
131
+ }