@reconcrap/boss-recommend-mcp 2.1.24 → 2.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.1.24",
3
+ "version": "2.1.25",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -277,11 +277,13 @@ export function resolveHumanBehaviorForRun(args = {}, config = {}) {
277
277
  override.enabled = rawBehavior;
278
278
  } else if (typeof rawBehavior === "string") {
279
279
  applyHumanBehaviorProfileDefaults(override, rawBehavior);
280
- } else if (rawBehavior && typeof rawBehavior === "object" && !Array.isArray(rawBehavior)) {
281
- const rawProfile = readFirstOwn(rawBehavior, ["profile", "mode", "behaviorProfile", "behavior_profile"]);
282
- if (rawProfile !== undefined) applyHumanBehaviorProfileDefaults(override, rawProfile);
283
- Object.assign(override, rawBehavior);
284
- }
280
+ } else if (rawBehavior && typeof rawBehavior === "object" && !Array.isArray(rawBehavior)) {
281
+ const rawProfile = readFirstOwn(rawBehavior, ["profile", "mode", "behaviorProfile", "behavior_profile"]);
282
+ if (rawProfile !== undefined) applyHumanBehaviorProfileDefaults(override, rawProfile);
283
+ Object.assign(override, rawBehavior);
284
+ const rawRestLevel = readFirstOwn(rawBehavior, ["restLevel", "rest_level"]);
285
+ if (rawRestLevel !== undefined) override.restLevel = rawRestLevel;
286
+ }
285
287
 
286
288
  const profile = readFirstOwn(args, ["human_behavior_profile", "humanBehaviorProfile"]);
287
289
  if (profile !== undefined) applyHumanBehaviorProfileDefaults(override, profile);
@@ -2375,13 +2375,14 @@ export function resolveHumanClickPointForBox(box, {
2375
2375
  };
2376
2376
  }
2377
2377
 
2378
- export async function clickPoint(client, x, y, {
2379
- button = "left",
2380
- clickCount = 1,
2381
- delayMs = 80,
2382
- humanRestEnabled = null,
2383
- humanInteraction = null
2384
- } = {}) {
2378
+ export async function clickPoint(client, x, y, {
2379
+ button = "left",
2380
+ clickCount = 1,
2381
+ delayMs = 80,
2382
+ moveBeforePress = true,
2383
+ humanRestEnabled = null,
2384
+ humanInteraction = null
2385
+ } = {}) {
2385
2386
  const configured = getHumanInteractionConfig(client);
2386
2387
  const mergedHumanInteraction = {
2387
2388
  ...(configured || {}),
@@ -2390,7 +2391,11 @@ export async function clickPoint(client, x, y, {
2390
2391
  const humanEnabled = humanRestEnabled === true
2391
2392
  || humanInteraction?.enabled === true
2392
2393
  || (humanRestEnabled !== false && configured?.enabled === true);
2393
- if (humanEnabled && mergedHumanInteraction.clickMovementEnabled !== false) {
2394
+ if (
2395
+ moveBeforePress !== false
2396
+ && humanEnabled
2397
+ && mergedHumanInteraction.clickMovementEnabled !== false
2398
+ ) {
2394
2399
  return simulateHumanClick(client, x, y, {
2395
2400
  ...mergedHumanInteraction,
2396
2401
  button,
@@ -2398,7 +2403,9 @@ export async function clickPoint(client, x, y, {
2398
2403
  delayMs
2399
2404
  });
2400
2405
  }
2401
- await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
2406
+ if (moveBeforePress !== false) {
2407
+ await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
2408
+ }
2402
2409
  await client.Input.dispatchMouseEvent({ type: "mousePressed", x, y, button, clickCount });
2403
2410
  if (delayMs > 0) await sleep(delayMs);
2404
2411
  await client.Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button, clickCount });
@@ -276,10 +276,11 @@ export function createRunLifecycleManager({
276
276
  });
277
277
  return;
278
278
  }
279
- setStatus(entry, RUN_STATUS_FAILED, {
280
- completedAt: now(),
281
- error: errorDiagnostic(error, entry.run.phase)
282
- });
279
+ setStatus(entry, RUN_STATUS_FAILED, {
280
+ completedAt: now(),
281
+ error: errorDiagnostic(error, entry.run.phase),
282
+ summary: error?.run_summary || entry.run.summary
283
+ });
283
284
  }
284
285
  }
285
286
 
@@ -37,6 +37,33 @@ const GENDER_CODE_MAP = {
37
37
  2: "女"
38
38
  };
39
39
 
40
+ const RECOMMEND_NETWORK_CANDIDATE_ID_KEYS = new Set([
41
+ "candidateid",
42
+ "encryptjid",
43
+ "encryptgeekid",
44
+ "gid",
45
+ "geekid",
46
+ "jid",
47
+ "securityid"
48
+ ]);
49
+
50
+ const RECOMMEND_PLACEHOLDER_PROFILE_NAMES = new Set([
51
+ "-",
52
+ "--",
53
+ "boss用户",
54
+ "候选人",
55
+ "匿名",
56
+ "匿名候选人",
57
+ "匿名用户",
58
+ "暂无",
59
+ "未知",
60
+ "未填写",
61
+ "用户",
62
+ "求职者",
63
+ "牛人",
64
+ "保密"
65
+ ]);
66
+
40
67
  const LLM_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "auto", "current"]);
41
68
  const LLM_SCREENING_STRATEGIES = new Set(["single_pass", "fast_first_verified"]);
42
69
  const FAST_FIRST_DEFAULT_FAST_MAX_TOKENS = 384;
@@ -756,6 +783,177 @@ function collectObjects(root, {
756
783
  return objects;
757
784
  }
758
785
 
786
+ function normalizeRecommendBindingCandidateId(value = "") {
787
+ if (!["string", "number"].includes(typeof value)) return "";
788
+ const raw = normalizeText(value);
789
+ if (!raw) return "";
790
+ try {
791
+ return normalizeText(decodeURIComponent(raw));
792
+ } catch {
793
+ return raw;
794
+ }
795
+ }
796
+
797
+ function normalizeRecommendBindingName(value = "") {
798
+ return normalizeText(value).normalize("NFKC").toLocaleLowerCase("zh-CN");
799
+ }
800
+
801
+ function isRecommendPlaceholderProfileName(value = "") {
802
+ const normalized = normalizeRecommendBindingName(value);
803
+ return !normalized
804
+ || RECOMMEND_PLACEHOLDER_PROFILE_NAMES.has(normalized)
805
+ || /^[-—–_**]+$/u.test(normalized);
806
+ }
807
+
808
+ function canonicalRecommendNetworkCandidateIdKey(value = "") {
809
+ return String(value || "").replace(/[^a-z0-9]/gi, "").toLowerCase();
810
+ }
811
+
812
+ function collectRecommendNetworkCandidateIdEvidence(networkBody = {}, profileRoots = []) {
813
+ const evidence = [];
814
+ const seen = new Set();
815
+ const add = (value, source) => {
816
+ const candidateId = normalizeRecommendBindingCandidateId(value);
817
+ if (!candidateId) return;
818
+ const key = `${candidateId}\u0000${source}`;
819
+ if (seen.has(key)) return;
820
+ seen.add(key);
821
+ evidence.push({ candidate_id: candidateId, source });
822
+ };
823
+
824
+ const rawUrl = normalizeText(networkBody?.url);
825
+ if (rawUrl) {
826
+ try {
827
+ const parsedUrl = new URL(rawUrl, "https://www.zhipin.com");
828
+ if (/\/geek\/info(?:\/|$)/i.test(parsedUrl.pathname)) {
829
+ for (const [key, value] of parsedUrl.searchParams.entries()) {
830
+ const canonicalKey = canonicalRecommendNetworkCandidateIdKey(key);
831
+ if (RECOMMEND_NETWORK_CANDIDATE_ID_KEYS.has(canonicalKey)) {
832
+ add(value, `url_query:${canonicalKey}`);
833
+ }
834
+ }
835
+ }
836
+ } catch {
837
+ // An unparseable response URL cannot prove candidate identity.
838
+ }
839
+ }
840
+
841
+ for (const object of profileRoots.filter(isPlainObject)) {
842
+ for (const [key, value] of Object.entries(object)) {
843
+ const canonicalKey = canonicalRecommendNetworkCandidateIdKey(key);
844
+ if (!RECOMMEND_NETWORK_CANDIDATE_ID_KEYS.has(canonicalKey)) continue;
845
+ add(value, `payload_profile_field:${canonicalKey}`);
846
+ }
847
+ }
848
+
849
+ return evidence.slice(0, 20);
850
+ }
851
+
852
+ function collectRecommendResponseCandidateIdDiagnostics(parsedObjects = []) {
853
+ const ids = [];
854
+ for (const object of collectObjects(parsedObjects, { maxObjects: 800, maxDepth: 10 })) {
855
+ for (const [key, value] of Object.entries(object)) {
856
+ const canonicalKey = canonicalRecommendNetworkCandidateIdKey(key);
857
+ if (!RECOMMEND_NETWORK_CANDIDATE_ID_KEYS.has(canonicalKey)) continue;
858
+ const candidateId = normalizeRecommendBindingCandidateId(value);
859
+ if (candidateId) ids.push(candidateId);
860
+ }
861
+ }
862
+ return [...new Set(ids)].slice(0, 10);
863
+ }
864
+
865
+ function bindRecommendNetworkProfileToCard(parsedProfile, cardCandidate) {
866
+ if (!parsedProfile?.ok) return parsedProfile;
867
+ const expectedCandidateId = normalizeRecommendBindingCandidateId(cardCandidate?.id);
868
+ const expectedName = normalizeText(cardCandidate?.identity?.name);
869
+ const normalizedExpectedName = normalizeRecommendBindingName(expectedName);
870
+ const profileName = normalizeText(parsedProfile?.profile?.identity?.name);
871
+ const normalizedProfileName = normalizeRecommendBindingName(profileName);
872
+ const candidateIdEvidence = Array.isArray(parsedProfile.candidate_id_evidence)
873
+ ? parsedProfile.candidate_id_evidence
874
+ : [];
875
+ const observedCandidateIds = [...new Set(candidateIdEvidence.map((item) => (
876
+ normalizeRecommendBindingCandidateId(item?.candidate_id)
877
+ )).filter(Boolean))].slice(0, 5);
878
+ const responseObservedCandidateIds = Array.isArray(parsedProfile.response_candidate_id_diagnostics)
879
+ ? parsedProfile.response_candidate_id_diagnostics.slice(0, 10)
880
+ : [];
881
+ const matchedCandidateIdEvidence = candidateIdEvidence.find((item) => (
882
+ normalizeRecommendBindingCandidateId(item?.candidate_id) === expectedCandidateId
883
+ )) || null;
884
+ const candidateIdConflict = observedCandidateIds.length > 1;
885
+ const candidateIdVerified = Boolean(
886
+ expectedCandidateId
887
+ && observedCandidateIds.length === 1
888
+ && observedCandidateIds[0] === expectedCandidateId
889
+ );
890
+ const profileNameUsable = !isRecommendPlaceholderProfileName(profileName);
891
+ const expectedNameUsable = !isRecommendPlaceholderProfileName(expectedName);
892
+ const nameVerified = Boolean(
893
+ expectedNameUsable
894
+ && profileNameUsable
895
+ && normalizedProfileName === normalizedExpectedName
896
+ );
897
+ let reason = null;
898
+ if (!expectedCandidateId) reason = "expected_candidate_id_missing";
899
+ else if (!candidateIdEvidence.length) reason = "network_candidate_id_evidence_missing";
900
+ else if (candidateIdConflict) reason = "network_candidate_id_conflict";
901
+ else if (!candidateIdVerified) reason = "network_candidate_id_mismatch";
902
+ else if (!expectedNameUsable) reason = "expected_card_name_placeholder_or_missing";
903
+ else if (!profileNameUsable) reason = "network_profile_name_placeholder_or_missing";
904
+ else if (!nameVerified) reason = "network_profile_name_mismatch";
905
+
906
+ const binding = {
907
+ required: true,
908
+ verified: !reason,
909
+ reason,
910
+ expected_candidate_id: expectedCandidateId || null,
911
+ matched_candidate_id_source: candidateIdVerified
912
+ ? matchedCandidateIdEvidence?.source || null
913
+ : null,
914
+ observed_candidate_id_count: candidateIdEvidence.length,
915
+ observed_candidate_ids: observedCandidateIds,
916
+ response_observed_candidate_ids: responseObservedCandidateIds,
917
+ expected_name: expectedName || null,
918
+ profile_name: profileName || null,
919
+ exact_name: nameVerified
920
+ };
921
+ if (!reason) {
922
+ return {
923
+ ...parsedProfile,
924
+ candidate_binding: binding
925
+ };
926
+ }
927
+
928
+ const { profile: _excludedProfile, ...diagnostic } = parsedProfile;
929
+ return {
930
+ ...diagnostic,
931
+ ok: false,
932
+ error: "RECOMMEND_NETWORK_PROFILE_CANDIDATE_BINDING_UNVERIFIED",
933
+ candidate_binding: binding
934
+ };
935
+ }
936
+
937
+ function compactNetworkProfileCandidateBinding(binding = null) {
938
+ if (!binding) return null;
939
+ return {
940
+ verified: binding.verified === true,
941
+ reason: binding.reason || null,
942
+ expected_candidate_id: binding.expected_candidate_id || null,
943
+ matched_candidate_id_source: binding.matched_candidate_id_source || null,
944
+ observed_candidate_id_count: Number(binding.observed_candidate_id_count) || 0,
945
+ observed_candidate_ids: Array.isArray(binding.observed_candidate_ids)
946
+ ? binding.observed_candidate_ids.slice(0, 5)
947
+ : [],
948
+ response_observed_candidate_ids: Array.isArray(binding.response_observed_candidate_ids)
949
+ ? binding.response_observed_candidate_ids.slice(0, 10)
950
+ : [],
951
+ expected_name: binding.expected_name || null,
952
+ profile_name: binding.profile_name || null,
953
+ exact_name: binding.exact_name === true
954
+ };
955
+ }
956
+
759
957
  function joinRange(start, end, fallback = "") {
760
958
  const left = parseDateLike(start);
761
959
  const right = parseDateLike(end);
@@ -889,8 +1087,29 @@ function resolveBossGeekDetail(payload = {}) {
889
1087
  return found || { sourceKey: "", detail: null };
890
1088
  }
891
1089
 
1090
+ function resolveBossChatGeekInfoData(payload = {}) {
1091
+ return payload?.zpData?.data
1092
+ || payload?.data
1093
+ || payload?.zpData?.geekInfo
1094
+ || payload?.geekInfo
1095
+ || null;
1096
+ }
1097
+
1098
+ function resolveBossChatHistoryResumeObject(payload = {}) {
1099
+ const messages = normalizeList(payload?.zpData?.messages).length
1100
+ ? normalizeList(payload?.zpData?.messages)
1101
+ : normalizeList(payload?.messages).length
1102
+ ? normalizeList(payload?.messages)
1103
+ : normalizeList(payload?.data?.messages).length
1104
+ ? normalizeList(payload?.data?.messages)
1105
+ : normalizeList(payload?.zpData?.data?.messages);
1106
+ return messages
1107
+ .map((message) => message?.body?.resume)
1108
+ .find((resume) => resume && typeof resume === "object") || null;
1109
+ }
1110
+
892
1111
  function extractBossChatGeekInfo(payload = {}) {
893
- const data = payload?.zpData?.data || payload?.data || payload?.zpData?.geekInfo || payload?.geekInfo;
1112
+ const data = resolveBossChatGeekInfoData(payload);
894
1113
  if (!data || typeof data !== "object") return null;
895
1114
  if (!isBossChatProfileShape(data)) return null;
896
1115
  const educationList = normalizeList(data.eduExpList);
@@ -961,17 +1180,7 @@ function extractBossChatGeekInfo(payload = {}) {
961
1180
  }
962
1181
 
963
1182
  function extractBossChatHistoryResume(payload = {}) {
964
- const messages = normalizeList(payload?.zpData?.messages).length
965
- ? normalizeList(payload?.zpData?.messages)
966
- : normalizeList(payload?.messages).length
967
- ? normalizeList(payload?.messages)
968
- : normalizeList(payload?.data?.messages).length
969
- ? normalizeList(payload?.data?.messages)
970
- : normalizeList(payload?.zpData?.data?.messages);
971
- const resumes = messages
972
- .map((message) => message?.body?.resume)
973
- .filter((resume) => resume && typeof resume === "object");
974
- const resume = resumes[0];
1183
+ const resume = resolveBossChatHistoryResumeObject(payload);
975
1184
  if (!resume) return null;
976
1185
  const user = resume.user || {};
977
1186
  const educationList = normalizeList(resume.education);
@@ -1177,6 +1386,10 @@ export function extractBossProfileFromNetworkBody(networkBody = {}) {
1177
1386
  tryParseJson(text),
1178
1387
  ...tryParseEmbeddedJsonObjects(text)
1179
1388
  ].filter((item) => item && typeof item === "object");
1389
+ const responseCandidateIdDiagnostics = collectRecommendResponseCandidateIdDiagnostics(
1390
+ parsedObjects
1391
+ );
1392
+ let candidateIdEvidence = collectRecommendNetworkCandidateIdEvidence(networkBody, []);
1180
1393
  if (!parsedObjects.length) {
1181
1394
  const htmlText = /<html|<body|<div|<section|<script/i.test(text) ? htmlToText(text) : "";
1182
1395
  if (htmlText && htmlText.length > 80) {
@@ -1191,6 +1404,8 @@ export function extractBossProfileFromNetworkBody(networkBody = {}) {
1191
1404
  status: networkBody.status ?? null,
1192
1405
  mimeType: networkBody.mimeType || null,
1193
1406
  text_length: text.length,
1407
+ candidate_id_evidence: candidateIdEvidence,
1408
+ response_candidate_id_diagnostics: responseCandidateIdDiagnostics,
1194
1409
  profile: {
1195
1410
  identity: candidate.identity,
1196
1411
  tags: candidate.tags,
@@ -1206,21 +1421,49 @@ export function extractBossProfileFromNetworkBody(networkBody = {}) {
1206
1421
  return {
1207
1422
  ok: false,
1208
1423
  error: "NETWORK_BODY_NOT_JSON",
1209
- text_length: text.length
1424
+ text_length: text.length,
1425
+ candidate_id_evidence: candidateIdEvidence,
1426
+ response_candidate_id_diagnostics: responseCandidateIdDiagnostics
1210
1427
  };
1211
1428
  }
1212
1429
  let profile = null;
1213
1430
  let parsed = parsedObjects[0];
1431
+ let profileBindingRoots = [];
1214
1432
  for (const candidateObject of parsedObjects) {
1215
- profile = extractBossGeekDetailInfo(candidateObject)
1216
- || extractBossChatGeekInfo(candidateObject)
1217
- || extractBossChatHistoryResume(candidateObject)
1218
- || extractBossProfileRecursively(candidateObject);
1433
+ const geekDetail = resolveBossGeekDetail(candidateObject).detail;
1434
+ if (geekDetail) {
1435
+ profile = extractBossGeekDetailInfo(candidateObject);
1436
+ profileBindingRoots = [
1437
+ geekDetail,
1438
+ geekDetail.geekBaseInfo,
1439
+ geekDetail.baseInfo,
1440
+ geekDetail.base
1441
+ ].filter(isPlainObject);
1442
+ } else {
1443
+ const chatData = resolveBossChatGeekInfoData(candidateObject);
1444
+ if (isBossChatProfileShape(chatData)) {
1445
+ profile = extractBossChatGeekInfo(candidateObject);
1446
+ profileBindingRoots = [chatData];
1447
+ } else {
1448
+ const historyResume = resolveBossChatHistoryResumeObject(candidateObject);
1449
+ if (historyResume) {
1450
+ profile = extractBossChatHistoryResume(candidateObject);
1451
+ profileBindingRoots = [historyResume, historyResume.user].filter(isPlainObject);
1452
+ } else {
1453
+ profile = extractBossProfileRecursively(candidateObject);
1454
+ profileBindingRoots = [];
1455
+ }
1456
+ }
1457
+ }
1219
1458
  if (profile) {
1220
1459
  parsed = candidateObject;
1221
1460
  break;
1222
1461
  }
1223
1462
  }
1463
+ candidateIdEvidence = collectRecommendNetworkCandidateIdEvidence(
1464
+ networkBody,
1465
+ profileBindingRoots
1466
+ );
1224
1467
  if (!profile) {
1225
1468
  const encryptedPayload = parsedObjects.find((item) => (
1226
1469
  normalizeText(item?.zpData?.encryptGeekDetailInfo || item?.encryptGeekDetailInfo || "")
@@ -1234,7 +1477,9 @@ export function extractBossProfileFromNetworkBody(networkBody = {}) {
1234
1477
  zpData_keys: Object.keys(parsed?.zpData || {}).slice(0, 50),
1235
1478
  data_keys: Object.keys(parsed?.data || parsed?.zpData?.data || {}).slice(0, 50),
1236
1479
  encrypted_resume: Boolean(encryptedPayload),
1237
- encrypted_resume_length: normalizeText(encryptedPayload?.zpData?.encryptGeekDetailInfo || encryptedPayload?.encryptGeekDetailInfo || "").length
1480
+ encrypted_resume_length: normalizeText(encryptedPayload?.zpData?.encryptGeekDetailInfo || encryptedPayload?.encryptGeekDetailInfo || "").length,
1481
+ candidate_id_evidence: candidateIdEvidence,
1482
+ response_candidate_id_diagnostics: responseCandidateIdDiagnostics
1238
1483
  };
1239
1484
  }
1240
1485
  return {
@@ -1243,6 +1488,8 @@ export function extractBossProfileFromNetworkBody(networkBody = {}) {
1243
1488
  status: networkBody.status ?? null,
1244
1489
  mimeType: networkBody.mimeType || null,
1245
1490
  text_length: text.length,
1491
+ candidate_id_evidence: candidateIdEvidence,
1492
+ response_candidate_id_diagnostics: responseCandidateIdDiagnostics,
1246
1493
  profile
1247
1494
  };
1248
1495
  }
@@ -1269,19 +1516,34 @@ export function buildScreeningCandidateFromDetail({
1269
1516
  source = "live-cdp-detail",
1270
1517
  metadata = {}
1271
1518
  } = {}) {
1272
- const parsedNetworkProfiles = networkBodies.map(extractBossProfileFromNetworkBody);
1519
+ const normalizedDomain = normalizeDomain(domain);
1520
+ const extractedNetworkProfiles = networkBodies.map(extractBossProfileFromNetworkBody);
1521
+ const parsedNetworkProfiles = normalizedDomain === "recommend"
1522
+ ? extractedNetworkProfiles.map((item) => bindRecommendNetworkProfileToCard(item, cardCandidate))
1523
+ : extractedNetworkProfiles;
1273
1524
  const successfulProfiles = parsedNetworkProfiles.filter((item) => item.ok).map((item) => item.profile);
1274
1525
  const networkText = successfulProfiles.map((profile) => profile.text).filter(Boolean).join("\n\n");
1275
1526
  const networkIdentity = mergeCandidateProfiles(
1276
1527
  ...successfulProfiles.map((profile) => profile.identity)
1277
1528
  );
1278
1529
  const networkTags = unique(successfulProfiles.flatMap((profile) => profile.tags || []));
1279
- const combinedIdentity = mergeCandidateProfiles(
1280
- networkIdentity,
1281
- cardCandidate?.identity
1282
- );
1530
+ const combinedIdentity = normalizedDomain === "recommend"
1531
+ ? mergeCandidateProfiles(cardCandidate?.identity, networkIdentity)
1532
+ : mergeCandidateProfiles(networkIdentity, cardCandidate?.identity);
1533
+ const networkProfileBinding = normalizedDomain === "recommend"
1534
+ ? {
1535
+ required: true,
1536
+ accepted_count: successfulProfiles.length,
1537
+ rejected_count: parsedNetworkProfiles.filter((item) => (
1538
+ item?.error === "RECOMMEND_NETWORK_PROFILE_CANDIDATE_BINDING_UNVERIFIED"
1539
+ )).length,
1540
+ profiles: parsedNetworkProfiles.map((item) => compactNetworkProfileCandidateBinding(
1541
+ item?.candidate_binding
1542
+ )).filter(Boolean)
1543
+ }
1544
+ : null;
1283
1545
  const candidate = normalizeCandidateProfile({
1284
- domain,
1546
+ domain: normalizedDomain,
1285
1547
  source,
1286
1548
  id: cardCandidate?.id,
1287
1549
  href: cardCandidate?.links?.href,
@@ -1300,19 +1562,22 @@ export function buildScreeningCandidateFromDetail({
1300
1562
  ...metadata,
1301
1563
  card_candidate_source: cardCandidate?.source || null,
1302
1564
  network_profile_count: successfulProfiles.length,
1565
+ network_profile_binding: networkProfileBinding,
1303
1566
  network_profiles: parsedNetworkProfiles.map((item) => ({
1304
1567
  ok: item.ok,
1305
1568
  url: item.url,
1306
1569
  status: item.status,
1307
1570
  error: item.error,
1308
1571
  text_length: item.text_length,
1309
- source_keys: item.profile?.source_keys || null
1572
+ source_keys: item.profile?.source_keys || null,
1573
+ candidate_binding: compactNetworkProfileCandidateBinding(item.candidate_binding)
1310
1574
  }))
1311
1575
  }
1312
1576
  });
1313
1577
  return {
1314
1578
  candidate,
1315
- parsed_network_profiles: parsedNetworkProfiles
1579
+ parsed_network_profiles: parsedNetworkProfiles,
1580
+ network_profile_binding: networkProfileBinding
1316
1581
  };
1317
1582
  }
1318
1583
 
@@ -239,14 +239,31 @@ function appendRunId(runIds, runId) {
239
239
 
240
240
  const EVIDENCE_KEYS = new Set([
241
241
  "action",
242
+ "action_hit_test_attempt_count",
243
+ "action_hit_test_last_hit_backend_node_id",
244
+ "action_hit_test_reason",
242
245
  "active_candidate_id",
243
246
  "ask_error",
244
247
  "ask_ok",
245
248
  "confirm_confirmed",
246
249
  "confirm_error",
250
+ "control_backend_node_id",
251
+ "control_center_x",
252
+ "control_center_y",
253
+ "control_label",
254
+ "control_node_id",
255
+ "control_root_backend_node_id",
256
+ "control_root_node_id",
257
+ "control_rect_height",
258
+ "control_rect_width",
259
+ "control_rect_x",
260
+ "control_rect_y",
261
+ "control_root",
247
262
  "greeting_baseline_count",
248
263
  "greeting_evidence_readable",
249
264
  "message_observed",
265
+ "operation_id",
266
+ "pre_input_cdp_method",
250
267
  "request_confirmation_source",
251
268
  "request_ready_state_observed",
252
269
  "reason",
@@ -271,6 +288,37 @@ function sanitizeEvidence(value = {}) {
271
288
  return result;
272
289
  }
273
290
 
291
+ function recordRevision(record) {
292
+ if (!record) return 0;
293
+ const historyLength = Array.isArray(record.history) ? record.history.length : 0;
294
+ const storedRevision = record.revision;
295
+ if (storedRevision == null) return historyLength;
296
+ const normalizedRevision = Number(storedRevision);
297
+ if (
298
+ !Number.isSafeInteger(normalizedRevision)
299
+ || normalizedRevision < 1
300
+ || normalizedRevision !== historyLength
301
+ ) {
302
+ throw journalError(
303
+ "CHAT_ACTION_JOURNAL_CORRUPT",
304
+ "Boss chat action journal revision does not exactly match its append-only history.",
305
+ {
306
+ stored_revision: storedRevision,
307
+ history_length: historyLength
308
+ }
309
+ );
310
+ }
311
+ return normalizedRevision;
312
+ }
313
+
314
+ function withRecordRevision(record) {
315
+ if (!record) return null;
316
+ return {
317
+ ...record,
318
+ revision: recordRevision(record)
319
+ };
320
+ }
321
+
274
322
  export function hashChatActionGreeting(greeting) {
275
323
  const normalized = normalizeGreeting(greeting);
276
324
  if (!normalized) {
@@ -318,7 +366,7 @@ export function createChatActionJournal({
318
366
  const filePath = path.join(resolvedBaseDir, `${identity.actionKey}.json`);
319
367
  const stored = readJsonRecord(filePath);
320
368
  if (!stored) return null;
321
- return cloneRecord(validateStoredRecord(stored, identity, filePath));
369
+ return cloneRecord(withRecordRevision(validateStoredRecord(stored, identity, filePath)));
322
370
  }
323
371
 
324
372
  function transition({
@@ -327,7 +375,10 @@ export function createChatActionJournal({
327
375
  state,
328
376
  runId = "",
329
377
  greeting,
330
- evidence = {}
378
+ evidence = {},
379
+ recordIdempotent = false,
380
+ expectedUpdatedAt = null,
381
+ expectedRevision = null
331
382
  } = {}) {
332
383
  const identity = createChatActionIdentity({ scope, candidateId });
333
384
  const nextState = requireState(state);
@@ -337,7 +388,47 @@ export function createChatActionJournal({
337
388
  const lockHandle = acquireRecordLock(lockPath);
338
389
  try {
339
390
  const stored = readJsonRecord(filePath);
340
- const existing = stored ? validateStoredRecord(stored, identity, filePath) : null;
391
+ const existing = stored
392
+ ? withRecordRevision(validateStoredRecord(stored, identity, filePath))
393
+ : null;
394
+ const observedRevision = recordRevision(existing);
395
+ if (expectedRevision != null) {
396
+ const normalizedExpectedRevision = Number(expectedRevision);
397
+ if (!Number.isSafeInteger(normalizedExpectedRevision) || normalizedExpectedRevision < 0) {
398
+ throw journalError(
399
+ "CHAT_ACTION_JOURNAL_REVISION_INVALID",
400
+ "Boss action journal expected revision must be a non-negative safe integer.",
401
+ { expected_revision: expectedRevision }
402
+ );
403
+ }
404
+ if (observedRevision !== normalizedExpectedRevision) {
405
+ throw journalError(
406
+ "CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE",
407
+ "Boss action journal changed after it was read; this operation does not own the outbound action.",
408
+ {
409
+ action_key: identity.actionKey,
410
+ expected_revision: normalizedExpectedRevision,
411
+ observed_revision: observedRevision,
412
+ expected_updated_at: normalizeText(expectedUpdatedAt) || null,
413
+ observed_updated_at: normalizeText(existing?.updated_at) || null
414
+ }
415
+ );
416
+ }
417
+ }
418
+ if (
419
+ expectedUpdatedAt != null
420
+ && normalizeText(existing?.updated_at) !== normalizeText(expectedUpdatedAt)
421
+ ) {
422
+ throw journalError(
423
+ "CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE",
424
+ "Boss action journal changed after it was read; this operation does not own the outbound action.",
425
+ {
426
+ action_key: identity.actionKey,
427
+ expected_updated_at: normalizeText(expectedUpdatedAt) || null,
428
+ observed_updated_at: normalizeText(existing?.updated_at) || null
429
+ }
430
+ );
431
+ }
341
432
  const suppliedGreeting = greeting == null ? "" : normalizeGreeting(greeting);
342
433
  const suppliedGreetingSha256 = suppliedGreeting ? hashChatActionGreeting(suppliedGreeting) : "";
343
434
 
@@ -369,7 +460,7 @@ export function createChatActionJournal({
369
460
  }
370
461
  );
371
462
  }
372
- if (existing?.state === nextState) {
463
+ if (existing?.state === nextState && recordIdempotent !== true) {
373
464
  return {
374
465
  changed: false,
375
466
  idempotent: true,
@@ -384,6 +475,7 @@ export function createChatActionJournal({
384
475
  const record = existing ? {
385
476
  ...existing,
386
477
  state: nextState,
478
+ revision: observedRevision + 1,
387
479
  updated_at: at,
388
480
  evidence: {
389
481
  ...(existing.evidence && typeof existing.evidence === "object" ? existing.evidence : {}),
@@ -407,6 +499,7 @@ export function createChatActionJournal({
407
499
  scope_sha256: identity.scopeSha256,
408
500
  candidate_id: identity.candidateId,
409
501
  state: nextState,
502
+ revision: 1,
410
503
  greeting_sha256: suppliedGreetingSha256,
411
504
  evidence: safeEvidence,
412
505
  created_at: at,