@reconcrap/boss-recommend-mcp 1.3.10 → 1.3.12

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.
@@ -1,8 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- const DEFAULT_STATE_TTL_HOURS = 24;
5
-
6
1
  function createInitialState(profileName) {
7
2
  return {
8
3
  version: 1,
@@ -13,131 +8,15 @@ function createInitialState(profileName) {
13
8
  };
14
9
  }
15
10
 
16
- function isRecoverableStateError(error) {
17
- if (!error) return false;
18
- if (error.code === 'ENOENT') return true;
19
- if (error.name === 'SyntaxError') return true;
20
- const message = String(error.message || '').toLowerCase();
21
- return (
22
- message.includes('unexpected end of json') ||
23
- message.includes('unexpected token') ||
24
- message.includes('json')
25
- );
26
- }
27
-
28
- function stateBackupPath(filePath) {
29
- const token = new Date().toISOString().replace(/[:.]/g, '-');
30
- return `${filePath}.corrupt-${token}.bak`;
31
- }
32
-
33
- function parsePositiveNumber(value, fallback) {
34
- const parsed = Number(value);
35
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
36
- }
37
-
38
- function resolveStateTtlMs(options = {}) {
39
- const hours = parsePositiveNumber(
40
- options.stateTtlHours ?? process.env.BOSS_CHAT_STATE_TTL_HOURS,
41
- DEFAULT_STATE_TTL_HOURS,
42
- );
43
- return Math.floor(hours * 60 * 60 * 1000);
44
- }
45
-
46
- function isEntryExpired(entry, nowMs, ttlMs) {
47
- if (!entry || typeof entry !== 'object') return true;
48
- const updatedAtRaw = String(entry.updatedAt || '').trim();
49
- if (!updatedAtRaw) return false;
50
- const updatedAtMs = Date.parse(updatedAtRaw);
51
- if (!Number.isFinite(updatedAtMs)) return false;
52
- return nowMs - updatedAtMs > ttlMs;
53
- }
54
-
55
- function pruneExpiredState(state, ttlMs, nowMs = Date.now()) {
56
- const nextCustomers = {};
57
- const nextAliases = {};
58
- const customers = state?.customers && typeof state.customers === 'object' ? state.customers : {};
59
- const aliases = state?.aliases && typeof state.aliases === 'object' ? state.aliases : {};
60
- let removedCount = 0;
61
-
62
- for (const [key, entry] of Object.entries(customers)) {
63
- if (isEntryExpired(entry, nowMs, ttlMs)) {
64
- removedCount += 1;
65
- continue;
66
- }
67
- nextCustomers[key] = entry;
68
- }
69
-
70
- for (const [alias, key] of Object.entries(aliases)) {
71
- if (typeof alias !== 'string' || !alias) continue;
72
- if (typeof key !== 'string' || !key) continue;
73
- if (!nextCustomers[key]) continue;
74
- nextAliases[alias] = key;
75
- }
76
-
77
- const changed =
78
- removedCount > 0 ||
79
- Object.keys(nextAliases).length !== Object.keys(aliases).length ||
80
- Object.keys(nextCustomers).length !== Object.keys(customers).length;
81
-
82
- return {
83
- changed,
84
- removedCount,
85
- customers: nextCustomers,
86
- aliases: nextAliases,
87
- };
88
- }
89
-
90
11
  export class StateStore {
91
- constructor(baseDir, profileName, options = {}) {
92
- this.baseDir = baseDir;
12
+ constructor(_baseDir, profileName, _options = {}) {
93
13
  this.profileName = profileName;
94
- this.statesDir = path.join(baseDir, 'state');
95
- this.filePath = path.join(this.statesDir, `${profileName}.json`);
96
14
  this.state = createInitialState(profileName);
97
- this.stateTtlMs = resolveStateTtlMs(options);
98
15
  }
99
16
 
100
17
  async load() {
101
- await mkdir(this.statesDir, { recursive: true });
102
- const defaults = createInitialState(this.profileName);
103
- try {
104
- const raw = await readFile(this.filePath, 'utf8');
105
- const parsed = JSON.parse(String(raw || ''));
106
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
107
- throw new SyntaxError('state file is not a JSON object');
108
- }
109
- this.state = {
110
- ...defaults,
111
- ...parsed,
112
- customers: {
113
- ...defaults.customers,
114
- ...(parsed.customers || {}),
115
- },
116
- aliases: {
117
- ...defaults.aliases,
118
- ...(parsed.aliases || {}),
119
- },
120
- };
121
-
122
- const pruned = pruneExpiredState(this.state, this.stateTtlMs);
123
- if (pruned.changed) {
124
- this.state.customers = pruned.customers;
125
- this.state.aliases = pruned.aliases;
126
- this.state.updatedAt = new Date().toISOString();
127
- await this.persistState();
128
- }
129
- } catch (error) {
130
- if (!isRecoverableStateError(error)) {
131
- throw error;
132
- }
133
-
134
- this.state = defaults;
135
- if (error?.code !== 'ENOENT') {
136
- const backupPath = stateBackupPath(this.filePath);
137
- await rename(this.filePath, backupPath);
138
- }
139
- await this.persistState();
140
- }
18
+ // Session-only dedup: each run starts with an empty state and does not persist cross-run history.
19
+ this.state = createInitialState(this.profileName);
141
20
  return this.state;
142
21
  }
143
22
 
@@ -181,15 +60,9 @@ export class StateStore {
181
60
  }
182
61
  }
183
62
  this.state.updatedAt = new Date().toISOString();
184
- await this.persistState();
185
63
  }
186
64
 
187
- async persistState() {
188
- await mkdir(this.statesDir, { recursive: true });
189
- const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
190
- await writeFile(tempPath, `${JSON.stringify(this.state, null, 2)}\n`, 'utf8');
191
- await rename(tempPath, this.filePath);
192
- }
65
+ async persistState() {}
193
66
  }
194
67
 
195
68
  export class NoopStateStore {
@@ -3949,7 +3949,8 @@ class RecommendScreenCli {
3949
3949
  const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
3950
3950
  const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
3951
3951
  try {
3952
- return await this.requestVisionModel(preparedPrimary.imagePaths);
3952
+ const primaryResult = await this.requestVisionModel(preparedPrimary.imagePaths);
3953
+ return this.applyVisionEvidenceGate(primaryResult);
3953
3954
  } catch (error) {
3954
3955
  if (!isVisionImageSizeLimitMessage(error?.message || "")) {
3955
3956
  throw error;
@@ -3963,7 +3964,8 @@ class RecommendScreenCli {
3963
3964
  }
3964
3965
  const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
3965
3966
  try {
3966
- return await this.requestVisionModel(preparedRetry.imagePaths);
3967
+ const retryResult = await this.requestVisionModel(preparedRetry.imagePaths);
3968
+ return this.applyVisionEvidenceGate(retryResult);
3967
3969
  } catch (retryError) {
3968
3970
  if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
3969
3971
  throw retryError;
@@ -3980,6 +3982,34 @@ class RecommendScreenCli {
3980
3982
  }
3981
3983
  }
3982
3984
 
3985
+ applyVisionEvidenceGate(result) {
3986
+ const parsed = result && typeof result === "object" ? result : {};
3987
+ const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
3988
+ const parsedEvidence = toStringArray(parsed?.evidence);
3989
+ const evidenceRawCount = Number.isFinite(Number(parsed?.evidenceRawCount))
3990
+ ? Number(parsed.evidenceRawCount)
3991
+ : parsedEvidence.length;
3992
+ const evidenceMatchedCount = Number.isFinite(Number(parsed?.evidenceMatchedCount))
3993
+ ? Number(parsed.evidenceMatchedCount)
3994
+ : parsedEvidence.length;
3995
+ const evidenceGateDemoted = parsed?.evidenceGateDemoted === true || (rawPassed && evidenceMatchedCount <= 0);
3996
+ const reason = normalizeText(parsed?.reason || "");
3997
+ const summary = normalizeText(parsed?.summary || reason);
3998
+ const finalReason = evidenceGateDemoted
3999
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4000
+ : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
4001
+ return {
4002
+ passed: evidenceGateDemoted ? false : rawPassed,
4003
+ rawPassed,
4004
+ reason: finalReason,
4005
+ summary: summary || finalReason,
4006
+ evidence: parsedEvidence,
4007
+ evidenceRawCount,
4008
+ evidenceMatchedCount,
4009
+ evidenceGateDemoted
4010
+ };
4011
+ }
4012
+
3983
4013
  async prepareVisionImageSegmentsForModel(imagePath, maxPixels, attemptTag = "primary") {
3984
4014
  const resolvedMaxPixels = parsePositiveInteger(maxPixels);
3985
4015
  if (!resolvedMaxPixels) {
@@ -4234,14 +4264,23 @@ class RecommendScreenCli {
4234
4264
  ? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
4235
4265
  : json?.choices?.[0]?.message?.content || "";
4236
4266
  const parsed = extractJsonObject(content);
4267
+ const rawPassed = parsed.passed === true;
4237
4268
  const reason = normalizeText(parsed.reason);
4238
4269
  const summary = normalizeText(parsed.summary || reason);
4239
4270
  const evidence = toStringArray(parsed.evidence);
4271
+ const evidenceGateDemoted = rawPassed && evidence.length <= 0;
4272
+ const finalReason = evidenceGateDemoted
4273
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4274
+ : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
4240
4275
  return {
4241
- passed: parsed.passed === true,
4242
- reason: reason || "未满足筛选标准。",
4243
- summary: summary || reason || "未满足筛选标准。",
4244
- evidence
4276
+ passed: evidenceGateDemoted ? false : rawPassed,
4277
+ rawPassed,
4278
+ reason: finalReason,
4279
+ summary: summary || finalReason,
4280
+ evidence,
4281
+ evidenceRawCount: evidence.length,
4282
+ evidenceMatchedCount: evidence.length,
4283
+ evidenceGateDemoted
4245
4284
  };
4246
4285
  }
4247
4286
 
@@ -1427,6 +1427,30 @@ async function testCloseDetailPageShouldContinueWhenListReady() {
1427
1427
  assert.equal(closed, true);
1428
1428
  }
1429
1429
 
1430
+ async function testVisionEvidenceGateShouldDemoteImageFallbackWithoutEvidence() {
1431
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-vision-evidence-gate-"));
1432
+ const cli = new RecommendScreenCli(createArgs(tempDir));
1433
+ cli.prepareVisionImageSegmentsForModel = async () => ({
1434
+ imagePaths: ["segment-1"],
1435
+ source: "test",
1436
+ sourcePixels: 100,
1437
+ currentPixels: 100
1438
+ });
1439
+ cli.requestVisionModel = async () => ({
1440
+ passed: true,
1441
+ rawPassed: true,
1442
+ reason: "matched",
1443
+ summary: "matched",
1444
+ evidence: []
1445
+ });
1446
+ const result = await cli.callVisionModel(path.join(tempDir, "fake.png"));
1447
+ assert.equal(result.rawPassed, true);
1448
+ assert.equal(result.passed, false);
1449
+ assert.equal(result.evidenceGateDemoted, true);
1450
+ assert.equal(result.evidenceRawCount, 0);
1451
+ assert.equal(result.evidenceMatchedCount, 0);
1452
+ }
1453
+
1430
1454
  async function main() {
1431
1455
  testShouldAbortResumeProbeEarly();
1432
1456
  await testSingleResumeCaptureFailureIsSkipped();
@@ -1470,6 +1494,7 @@ async function main() {
1470
1494
  await testCallTextModelShouldNotTruncateLongResume();
1471
1495
  await testCallTextModelShouldFallbackToChunkModeOnContextLimit();
1472
1496
  await testPrepareVisionImageSegmentsShouldSplitLongImage();
1497
+ await testVisionEvidenceGateShouldDemoteImageFallbackWithoutEvidence();
1473
1498
  testRecoverablePostActionErrorShouldTreatGreetContinueAndNoButtonAsRecoverable();
1474
1499
  await testRecoverableGreetContinueButtonShouldNotAbortWhenDetailCloseFails();
1475
1500
  await testRecoverableGreetButtonNotFoundShouldNotAbortWhenDetailCloseFails();