@reconcrap/boss-recommend-mcp 1.3.10 → 1.3.11
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 +1 -1
- package/src/test-boss-chat.js +258 -0
- package/vendor/boss-chat-cli/src/app.js +61 -4
- package/vendor/boss-chat-cli/src/browser/chat-page.js +75 -0
- package/vendor/boss-chat-cli/src/cli.js +1 -1
- package/vendor/boss-chat-cli/src/services/llm.js +393 -52
- package/vendor/boss-chat-cli/src/services/state-store.js +4 -131
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +45 -6
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +25 -0
|
@@ -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(
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
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();
|