@yuzc-001/grasp 0.6.6
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/LICENSE +21 -0
- package/README.md +327 -0
- package/README.zh-CN.md +324 -0
- package/examples/README.md +31 -0
- package/examples/claude-desktop.json +8 -0
- package/examples/codex-config.toml +4 -0
- package/grasp.skill +0 -0
- package/index.js +87 -0
- package/package.json +48 -0
- package/scripts/grasp_openclaw_ctl.sh +122 -0
- package/scripts/run-search-benchmark.mjs +287 -0
- package/scripts/update-star-history.mjs +274 -0
- package/skill/SKILL.md +61 -0
- package/skill/references/tools.md +306 -0
- package/src/cli/auto-configure.js +116 -0
- package/src/cli/cmd-connect.js +148 -0
- package/src/cli/cmd-explain.js +42 -0
- package/src/cli/cmd-logs.js +55 -0
- package/src/cli/cmd-status.js +119 -0
- package/src/cli/config.js +27 -0
- package/src/cli/detect-chrome.js +58 -0
- package/src/grasp/handoff/events.js +67 -0
- package/src/grasp/handoff/persist.js +48 -0
- package/src/grasp/handoff/state.js +28 -0
- package/src/grasp/page/capture.js +34 -0
- package/src/grasp/page/state.js +273 -0
- package/src/grasp/verify/evidence.js +40 -0
- package/src/grasp/verify/pipeline.js +52 -0
- package/src/layer1-bridge/chrome.js +416 -0
- package/src/layer1-bridge/webmcp.js +143 -0
- package/src/layer2-perception/hints.js +284 -0
- package/src/layer3-action/actions.js +400 -0
- package/src/runtime/browser-instance.js +65 -0
- package/src/runtime/truth/model.js +94 -0
- package/src/runtime/truth/snapshot.js +51 -0
- package/src/server/affordances.js +47 -0
- package/src/server/audit.js +122 -0
- package/src/server/boss-fast-path.js +164 -0
- package/src/server/boundary-guard.js +53 -0
- package/src/server/content.js +97 -0
- package/src/server/continuity.js +256 -0
- package/src/server/engine-selection.js +29 -0
- package/src/server/entry-orchestrator.js +115 -0
- package/src/server/error-codes.js +7 -0
- package/src/server/explain-share-card.js +113 -0
- package/src/server/fast-path-router.js +134 -0
- package/src/server/form-runtime.js +602 -0
- package/src/server/form-tasks.js +254 -0
- package/src/server/gateway-response.js +62 -0
- package/src/server/index.js +22 -0
- package/src/server/observe.js +52 -0
- package/src/server/page-projection.js +31 -0
- package/src/server/page-state.js +27 -0
- package/src/server/postconditions.js +128 -0
- package/src/server/prompt-assembly.js +148 -0
- package/src/server/responses.js +44 -0
- package/src/server/route-boundary.js +174 -0
- package/src/server/route-policy.js +168 -0
- package/src/server/runtime-confirmation.js +87 -0
- package/src/server/runtime-status.js +7 -0
- package/src/server/share-artifacts.js +284 -0
- package/src/server/state.js +132 -0
- package/src/server/structured-extraction.js +131 -0
- package/src/server/surface-prompts.js +166 -0
- package/src/server/task-frame.js +11 -0
- package/src/server/tasks/search-task.js +321 -0
- package/src/server/tools.actions.js +1361 -0
- package/src/server/tools.form.js +526 -0
- package/src/server/tools.gateway.js +757 -0
- package/src/server/tools.handoff.js +210 -0
- package/src/server/tools.js +20 -0
- package/src/server/tools.legacy.js +983 -0
- package/src/server/tools.strategy.js +250 -0
- package/src/server/tools.task-surface.js +66 -0
- package/src/server/tools.workspace.js +873 -0
- package/src/server/workspace-runtime.js +1138 -0
- package/src/server/workspace-tasks.js +735 -0
- package/start-chrome.bat +84 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT = 1500;
|
|
2
|
+
|
|
3
|
+
export function inferBrowserInstance(versionInfo = {}) {
|
|
4
|
+
const browser = typeof versionInfo?.Browser === 'string'
|
|
5
|
+
? versionInfo.Browser
|
|
6
|
+
: null;
|
|
7
|
+
const protocolVersion = typeof versionInfo?.['Protocol-Version'] === 'string'
|
|
8
|
+
? versionInfo['Protocol-Version']
|
|
9
|
+
: null;
|
|
10
|
+
const normalized = browser?.toLowerCase() ?? '';
|
|
11
|
+
|
|
12
|
+
let headless = null;
|
|
13
|
+
if (normalized.includes('headlesschrome/')) {
|
|
14
|
+
headless = true;
|
|
15
|
+
} else if (normalized.includes('chrome/')) {
|
|
16
|
+
headless = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const display = headless === true
|
|
20
|
+
? 'headless'
|
|
21
|
+
: headless === false
|
|
22
|
+
? 'windowed'
|
|
23
|
+
: 'unknown';
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
browser,
|
|
27
|
+
protocolVersion,
|
|
28
|
+
headless,
|
|
29
|
+
display,
|
|
30
|
+
warning: headless === true
|
|
31
|
+
? 'Current endpoint is a headless browser, not a visible local browser window.'
|
|
32
|
+
: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function requireVisibleBrowserInstance(instance, contextLabel = 'This check') {
|
|
37
|
+
if (instance?.headless === false) return null;
|
|
38
|
+
|
|
39
|
+
const label = typeof contextLabel === 'string' && contextLabel.trim()
|
|
40
|
+
? contextLabel.trim()
|
|
41
|
+
: 'This check';
|
|
42
|
+
|
|
43
|
+
if (instance?.headless === true) {
|
|
44
|
+
const browser = instance.browser ?? 'unknown browser';
|
|
45
|
+
return `${label} requires a visible local browser window. Current browser: ${browser}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `${label} requires a visible local browser window. Current browser could not be identified.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readBrowserInstance(cdpUrl, {
|
|
52
|
+
fetchImpl = fetch,
|
|
53
|
+
timeout = DEFAULT_TIMEOUT,
|
|
54
|
+
} = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(`${cdpUrl}/json/version`, {
|
|
57
|
+
signal: AbortSignal.timeout(timeout),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) return null;
|
|
60
|
+
const versionInfo = await res.json();
|
|
61
|
+
return inferBrowserInstance(versionInfo);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function createRuntimeTruth(overrides = {}) {
|
|
2
|
+
return {
|
|
3
|
+
browser_process: {
|
|
4
|
+
state: 'unknown', // unknown | running | stopped
|
|
5
|
+
pid: null,
|
|
6
|
+
},
|
|
7
|
+
cdp: {
|
|
8
|
+
state: 'unknown', // unknown | connected | disconnected | unreachable
|
|
9
|
+
url: process.env.CHROME_CDP_URL || 'http://localhost:9222',
|
|
10
|
+
browserVersion: null,
|
|
11
|
+
protocolVersion: null,
|
|
12
|
+
},
|
|
13
|
+
page: {
|
|
14
|
+
state: 'unknown', // unknown | available | unavailable
|
|
15
|
+
title: null,
|
|
16
|
+
url: null,
|
|
17
|
+
},
|
|
18
|
+
server: {
|
|
19
|
+
state: 'idle', // idle | connecting | connected | disconnected | error
|
|
20
|
+
lastError: null,
|
|
21
|
+
retryCount: 0,
|
|
22
|
+
connectedAt: null,
|
|
23
|
+
lastAttemptAt: null,
|
|
24
|
+
},
|
|
25
|
+
profile: {
|
|
26
|
+
state: 'unknown',
|
|
27
|
+
path: null,
|
|
28
|
+
},
|
|
29
|
+
updatedAt: Date.now(),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function mergeRuntimeTruth(base, patch = {}) {
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
...patch,
|
|
38
|
+
browser_process: { ...(base.browser_process ?? {}), ...(patch.browser_process ?? {}) },
|
|
39
|
+
cdp: { ...(base.cdp ?? {}), ...(patch.cdp ?? {}) },
|
|
40
|
+
page: { ...(base.page ?? {}), ...(patch.page ?? {}) },
|
|
41
|
+
server: { ...(base.server ?? {}), ...(patch.server ?? {}) },
|
|
42
|
+
profile: { ...(base.profile ?? {}), ...(patch.profile ?? {}) },
|
|
43
|
+
updatedAt: patch.updatedAt ?? Date.now(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function legacyRuntimeStatusToTruth(snapshot) {
|
|
48
|
+
if (!snapshot) return createRuntimeTruth();
|
|
49
|
+
|
|
50
|
+
const serverState = snapshot.state === 'CDP_UNREACHABLE'
|
|
51
|
+
? 'error'
|
|
52
|
+
: (snapshot.state ?? 'unknown');
|
|
53
|
+
const cdpState = snapshot.state === 'connected'
|
|
54
|
+
? 'connected'
|
|
55
|
+
: snapshot.state === 'CDP_UNREACHABLE'
|
|
56
|
+
? 'unreachable'
|
|
57
|
+
: 'disconnected';
|
|
58
|
+
|
|
59
|
+
return createRuntimeTruth({
|
|
60
|
+
cdp: {
|
|
61
|
+
state: cdpState,
|
|
62
|
+
url: snapshot.cdpUrl ?? (process.env.CHROME_CDP_URL || 'http://localhost:9222'),
|
|
63
|
+
browserVersion: null,
|
|
64
|
+
protocolVersion: null,
|
|
65
|
+
},
|
|
66
|
+
server: {
|
|
67
|
+
state: serverState,
|
|
68
|
+
lastError: snapshot.lastError ?? null,
|
|
69
|
+
retryCount: snapshot.retryCount ?? 0,
|
|
70
|
+
connectedAt: snapshot.connectedAt ?? null,
|
|
71
|
+
lastAttemptAt: snapshot.lastAttemptAt ?? null,
|
|
72
|
+
},
|
|
73
|
+
updatedAt: snapshot.updatedAt ?? Date.now(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function truthToLegacyRuntimeStatus(truth) {
|
|
78
|
+
const cdpState = truth?.cdp?.state;
|
|
79
|
+
let state = 'idle';
|
|
80
|
+
if (cdpState === 'connected') state = 'connected';
|
|
81
|
+
else if (cdpState === 'unreachable') state = 'CDP_UNREACHABLE';
|
|
82
|
+
else if (truth?.server?.state) state = truth.server.state;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
state,
|
|
86
|
+
retryCount: truth?.server?.retryCount ?? 0,
|
|
87
|
+
lastError: truth?.server?.lastError ?? null,
|
|
88
|
+
lastAttemptAt: truth?.server?.lastAttemptAt ?? null,
|
|
89
|
+
connectedAt: truth?.server?.connectedAt ?? null,
|
|
90
|
+
cdpUrl: truth?.cdp?.url ?? (process.env.CHROME_CDP_URL || 'http://localhost:9222'),
|
|
91
|
+
safeMode: process.env.GRASP_SAFE_MODE !== 'false',
|
|
92
|
+
updatedAt: truth?.updatedAt ?? Date.now(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import {
|
|
5
|
+
createRuntimeTruth,
|
|
6
|
+
legacyRuntimeStatusToTruth,
|
|
7
|
+
mergeRuntimeTruth,
|
|
8
|
+
truthToLegacyRuntimeStatus,
|
|
9
|
+
} from './model.js';
|
|
10
|
+
|
|
11
|
+
export const RUNTIME_STATUS_PATH =
|
|
12
|
+
process.env.GRASP_RUNTIME_STATUS_PATH ??
|
|
13
|
+
join(homedir(), '.grasp', 'runtime-status.json');
|
|
14
|
+
|
|
15
|
+
async function ensureDir() {
|
|
16
|
+
await mkdir(dirname(RUNTIME_STATUS_PATH), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function writeRuntimeTruth(snapshot) {
|
|
20
|
+
try {
|
|
21
|
+
await ensureDir();
|
|
22
|
+
const truth = mergeRuntimeTruth(createRuntimeTruth(), snapshot);
|
|
23
|
+
await writeFile(RUNTIME_STATUS_PATH, JSON.stringify(truth, null, 2) + '\n', 'utf8');
|
|
24
|
+
} catch {
|
|
25
|
+
// best effort
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readRuntimeTruth() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(RUNTIME_STATUS_PATH, 'utf8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
|
|
34
|
+
if (parsed?.browser_process || parsed?.cdp || parsed?.server || parsed?.page || parsed?.profile) {
|
|
35
|
+
return mergeRuntimeTruth(createRuntimeTruth(), parsed);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return legacyRuntimeStatusToTruth(parsed);
|
|
39
|
+
} catch {
|
|
40
|
+
return createRuntimeTruth();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function writeRuntimeStatus(snapshot) {
|
|
45
|
+
return writeRuntimeTruth(legacyRuntimeStatusToTruth(snapshot));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readRuntimeStatus() {
|
|
49
|
+
const truth = await readRuntimeTruth();
|
|
50
|
+
return truthToLegacyRuntimeStatus(truth);
|
|
51
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const SEARCH_KEYWORDS = /search|搜索|提问|ask|query/i;
|
|
2
|
+
const INPUT_TYPES = new Set(['searchbox', 'textbox', 'combobox', 'input', 'textarea']);
|
|
3
|
+
|
|
4
|
+
function isInputLikeHint(hint = {}) {
|
|
5
|
+
if (INPUT_TYPES.has(hint.type)) return true;
|
|
6
|
+
if (hint.meta?.contenteditable === true || hint.meta?.contenteditable === 'true') return true;
|
|
7
|
+
if (hint.meta?.tag === 'input' || hint.meta?.tag === 'textarea') return true;
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function gatherHintText(hint) {
|
|
12
|
+
return [
|
|
13
|
+
hint.label,
|
|
14
|
+
hint.meta?.name,
|
|
15
|
+
hint.meta?.ariaLabel,
|
|
16
|
+
hint.meta?.placeholder,
|
|
17
|
+
]
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join(' ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scoreSearchInput(hint) {
|
|
23
|
+
if (!isInputLikeHint(hint)) return 0;
|
|
24
|
+
let score = 0;
|
|
25
|
+
if (isInputLikeHint(hint)) score += 5;
|
|
26
|
+
if (SEARCH_KEYWORDS.test(hint.label ?? '')) score += 5;
|
|
27
|
+
if (SEARCH_KEYWORDS.test(gatherHintText(hint))) score += 3;
|
|
28
|
+
return score;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function rankAffordances(snapshot = {}) {
|
|
32
|
+
const hints = (snapshot.hints ?? []).map((hint) => ({ ...hint }));
|
|
33
|
+
const scored = hints.map((hint) => ({ ...hint, score: scoreSearchInput(hint) }));
|
|
34
|
+
|
|
35
|
+
const search_input = scored
|
|
36
|
+
.filter((hint) => hint.score > 0)
|
|
37
|
+
.sort((a, b) => {
|
|
38
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
39
|
+
return (a.label ?? '').localeCompare(b.label ?? '');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const command_button = scored
|
|
43
|
+
.filter((hint) => hint.type === 'button')
|
|
44
|
+
.sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
|
45
|
+
|
|
46
|
+
return { search_input, command_button };
|
|
47
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, appendFile, readFile } from 'fs/promises';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const LOG_DIR = join(homedir(), '.grasp');
|
|
6
|
+
const LOG_PATH = join(LOG_DIR, 'audit.log');
|
|
7
|
+
|
|
8
|
+
function getAuditLogPath() {
|
|
9
|
+
return process.env.GRASP_AUDIT_LOG_PATH || LOG_PATH;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseAuditLine(line) {
|
|
13
|
+
const match = /^\[(.+?)\]\s+(\S+)\s+(.*)$/.exec(String(line ?? ''));
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
|
|
16
|
+
const [, timestamp, action, remainder] = match;
|
|
17
|
+
let detail = remainder.trim();
|
|
18
|
+
let meta = null;
|
|
19
|
+
const markerIndex = detail.lastIndexOf(' :: ');
|
|
20
|
+
|
|
21
|
+
if (markerIndex !== -1) {
|
|
22
|
+
const maybeJson = detail.slice(markerIndex + 4);
|
|
23
|
+
try {
|
|
24
|
+
meta = JSON.parse(maybeJson);
|
|
25
|
+
detail = detail.slice(0, markerIndex).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
meta = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
timestamp,
|
|
33
|
+
action,
|
|
34
|
+
detail,
|
|
35
|
+
meta,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Append one audit entry. Fire-and-forget — failures are silently ignored.
|
|
41
|
+
* @param {string} action e.g. 'click', 'navigate', 'type'
|
|
42
|
+
* @param {string} detail e.g. '[B1] "发送"'
|
|
43
|
+
* @param {object|null} meta Optional metadata
|
|
44
|
+
* @param {object|null} state Optional server state for task-awareness
|
|
45
|
+
*/
|
|
46
|
+
export async function audit(action, detail, meta = null, state = null) {
|
|
47
|
+
const taskId = state?.activeTaskId ?? null;
|
|
48
|
+
|
|
49
|
+
if (state && taskId) {
|
|
50
|
+
const frame = state.taskFrames?.get(taskId);
|
|
51
|
+
if (frame) {
|
|
52
|
+
frame.history = frame.history || [];
|
|
53
|
+
frame.history.push({
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
action,
|
|
56
|
+
detail,
|
|
57
|
+
meta
|
|
58
|
+
});
|
|
59
|
+
if (frame.history.length > 0) {
|
|
60
|
+
// Cap history to prevent memory bloat
|
|
61
|
+
if (frame.history.length > 100) {
|
|
62
|
+
frame.history.shift();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const taskMarker = taskId ? ` <${taskId}>` : '';
|
|
70
|
+
const logPath = getAuditLogPath();
|
|
71
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
72
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
73
|
+
const suffix = meta ? ` :: ${JSON.stringify(meta)}` : '';
|
|
74
|
+
const line = `[${ts}] ${action.padEnd(14)}${taskMarker} ${detail}${suffix}\n`;
|
|
75
|
+
await appendFile(logPath, line, 'utf8');
|
|
76
|
+
} catch {
|
|
77
|
+
// Logging failures must never affect the main tool flow.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function auditRouteDecision(trace, state = null) {
|
|
82
|
+
const intent = trace?.intent ?? 'unknown';
|
|
83
|
+
const mode = trace?.selected_mode ?? 'unknown';
|
|
84
|
+
const url = trace?.url ?? 'unknown';
|
|
85
|
+
await audit('route_decision', `${intent} -> ${mode} @ ${url}`, trace, state);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the last N lines from the audit log.
|
|
90
|
+
* @param {number} [n=50]
|
|
91
|
+
* @returns {Promise<string[]>}
|
|
92
|
+
*/
|
|
93
|
+
export async function readLogs(n = 50) {
|
|
94
|
+
try {
|
|
95
|
+
const content = await readFile(getAuditLogPath(), 'utf8');
|
|
96
|
+
const lines = content.split('\n').filter(Boolean);
|
|
97
|
+
return lines.slice(-n);
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function readLatestRouteDecision() {
|
|
104
|
+
try {
|
|
105
|
+
const content = await readFile(getAuditLogPath(), 'utf8');
|
|
106
|
+
const lines = content.split('\n').filter(Boolean).reverse();
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const entry = parseAuditLine(line);
|
|
110
|
+
if (entry?.action !== 'route_decision' || !entry.meta) continue;
|
|
111
|
+
return {
|
|
112
|
+
timestamp: entry.timestamp,
|
|
113
|
+
detail: entry.detail,
|
|
114
|
+
...entry.meta,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export function detectBossSurface(url, signals = {}) {
|
|
2
|
+
const currentUrl = String(url ?? '');
|
|
3
|
+
|
|
4
|
+
if (!currentUrl.includes('zhipin.com')) {
|
|
5
|
+
return { surface: 'non_boss' };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (signals.hasComposer || signals.hasSendButton || currentUrl.includes('/web/geek/chat')) {
|
|
9
|
+
return { surface: 'chat' };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (signals.hasChatEntry || currentUrl.includes('job_detail')) {
|
|
13
|
+
return { surface: 'detail' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (signals.hasSearchLinks || currentUrl.includes('/web/geek/jobs')) {
|
|
17
|
+
return { surface: 'search' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { surface: 'non_boss' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readBossSearchSurface(page) {
|
|
24
|
+
const currentUrl = page.url();
|
|
25
|
+
const jobs = await page.evaluate(() => {
|
|
26
|
+
return Array.from(document.querySelectorAll('a[href*="job_detail"]'))
|
|
27
|
+
.map((node) => {
|
|
28
|
+
const title = String(node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim();
|
|
29
|
+
const href = node.getAttribute?.('href') ?? node.href ?? '';
|
|
30
|
+
return title ? { title, href } : null;
|
|
31
|
+
})
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
});
|
|
34
|
+
return { currentUrl, jobs };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readBossJobDetailSurface(page) {
|
|
38
|
+
const currentUrl = page.url();
|
|
39
|
+
const result = await page.evaluate(() => {
|
|
40
|
+
const title = String(document.title || '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
const chatNode = document.querySelector('[data-url*="/wapi/zpgeek/friend/add.json"]');
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
title,
|
|
45
|
+
chatEntry: chatNode ? {
|
|
46
|
+
text: String(chatNode.innerText || chatNode.textContent || '').replace(/\s+/g, ' ').trim(),
|
|
47
|
+
redirectUrl: chatNode.getAttribute?.('redirect-url')
|
|
48
|
+
?? chatNode.getAttribute?.('data-url')
|
|
49
|
+
?? null,
|
|
50
|
+
} : null,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return { currentUrl, ...result };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readBossChatSurface(page) {
|
|
57
|
+
const currentUrl = page.url();
|
|
58
|
+
const result = await page.evaluate(() => {
|
|
59
|
+
const composer = document.querySelector('.chat-input[contenteditable="true"]');
|
|
60
|
+
const sendButton = document.querySelector('button.btn-send');
|
|
61
|
+
const historyNodes = Array.from(document.querySelectorAll('.item-myself, .item-friend, .message-item'))
|
|
62
|
+
.slice(-3)
|
|
63
|
+
.map((node) => String(node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
composerText: String(composer?.innerText || composer?.textContent || '').replace(/\s+/g, ' ').trim(),
|
|
68
|
+
sendButtonText: String(sendButton?.innerText || sendButton?.textContent || '').replace(/\s+/g, ' ').trim(),
|
|
69
|
+
historySignal: historyNodes.join('\n'),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { currentUrl, ...result };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildFastPathContent({ surface, title, url, text }) {
|
|
76
|
+
const normalizedTitle = String(title ?? '').trim() || 'BOSS';
|
|
77
|
+
const normalizedText = String(text ?? '').trim() || normalizedTitle;
|
|
78
|
+
return {
|
|
79
|
+
surface,
|
|
80
|
+
title: normalizedTitle,
|
|
81
|
+
url,
|
|
82
|
+
mainText: normalizedText,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const bossFastPathAdapter = {
|
|
87
|
+
id: 'boss',
|
|
88
|
+
matches(url) {
|
|
89
|
+
try {
|
|
90
|
+
const { hostname } = new URL(String(url ?? ''));
|
|
91
|
+
return hostname === 'bosszhipin.com'
|
|
92
|
+
|| hostname === 'zhipin.com'
|
|
93
|
+
|| hostname.endsWith('.bosszhipin.com')
|
|
94
|
+
|| hostname.endsWith('.zhipin.com');
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async read(page) {
|
|
100
|
+
const currentUrl = page.url();
|
|
101
|
+
if (!this.matches(currentUrl)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [title, pageInfo] = await Promise.all([
|
|
106
|
+
page.title(),
|
|
107
|
+
page.evaluate(() => ({
|
|
108
|
+
hasComposer: Boolean(document.querySelector('.chat-input[contenteditable="true"]')),
|
|
109
|
+
hasSendButton: Boolean(document.querySelector('button.btn-send')),
|
|
110
|
+
hasChatEntry: Boolean(document.querySelector('[data-url*="/wapi/zpgeek/friend/add.json"]')),
|
|
111
|
+
hasSearchLinks: Boolean(document.querySelector('a[href*="job_detail"]')),
|
|
112
|
+
})),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const { surface } = detectBossSurface(currentUrl, {
|
|
116
|
+
title,
|
|
117
|
+
...pageInfo,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (surface === 'non_boss') {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (surface === 'search') {
|
|
125
|
+
const result = await readBossSearchSurface(page);
|
|
126
|
+
return buildFastPathContent({
|
|
127
|
+
surface,
|
|
128
|
+
title,
|
|
129
|
+
url: result.currentUrl,
|
|
130
|
+
text: (result.jobs ?? []).map((job) => job.title).join('\n'),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (surface === 'detail') {
|
|
135
|
+
const result = await readBossJobDetailSurface(page);
|
|
136
|
+
return buildFastPathContent({
|
|
137
|
+
surface,
|
|
138
|
+
title: result.title || title,
|
|
139
|
+
url: result.currentUrl,
|
|
140
|
+
text: [
|
|
141
|
+
result.title,
|
|
142
|
+
result.chatEntry?.text,
|
|
143
|
+
result.chatEntry?.redirectUrl,
|
|
144
|
+
].filter(Boolean).join('\n'),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (surface === 'chat') {
|
|
149
|
+
const result = await readBossChatSurface(page);
|
|
150
|
+
return buildFastPathContent({
|
|
151
|
+
surface,
|
|
152
|
+
title,
|
|
153
|
+
url: result.currentUrl,
|
|
154
|
+
text: [
|
|
155
|
+
result.composerText,
|
|
156
|
+
result.sendButtonText,
|
|
157
|
+
result.historySignal,
|
|
158
|
+
].filter(Boolean).join('\n'),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildGatewayResponse } from './gateway-response.js';
|
|
2
|
+
import { BOUNDARY_MISMATCH } from './error-codes.js';
|
|
3
|
+
import {
|
|
4
|
+
buildBoundaryContinuation,
|
|
5
|
+
buildBoundaryMismatchLines,
|
|
6
|
+
inferSurfaceBoundaryKey,
|
|
7
|
+
} from './route-boundary.js';
|
|
8
|
+
|
|
9
|
+
function getBoundaryStatus(status, currentBoundary) {
|
|
10
|
+
if (status === 'handoff_required' || status === 'gated' || status === 'warmup') {
|
|
11
|
+
return status;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (currentBoundary === 'session_warmup') {
|
|
15
|
+
return 'warmup';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return 'blocked';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function guardExpectedBoundary({
|
|
22
|
+
toolName,
|
|
23
|
+
expectedBoundary,
|
|
24
|
+
status,
|
|
25
|
+
page,
|
|
26
|
+
handoffState = 'idle',
|
|
27
|
+
} = {}) {
|
|
28
|
+
const currentBoundary = inferSurfaceBoundaryKey({ page });
|
|
29
|
+
if (!currentBoundary || currentBoundary === expectedBoundary) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const continuation = buildBoundaryContinuation(currentBoundary, handoffState);
|
|
34
|
+
return buildGatewayResponse({
|
|
35
|
+
status: getBoundaryStatus(status, currentBoundary),
|
|
36
|
+
page,
|
|
37
|
+
result: {
|
|
38
|
+
status: 'blocked',
|
|
39
|
+
reason: 'boundary_mismatch',
|
|
40
|
+
expected_boundary: expectedBoundary,
|
|
41
|
+
current_boundary: currentBoundary,
|
|
42
|
+
next_step: continuation.suggested_next_action,
|
|
43
|
+
},
|
|
44
|
+
continuation,
|
|
45
|
+
error_code: BOUNDARY_MISMATCH,
|
|
46
|
+
message: buildBoundaryMismatchLines({
|
|
47
|
+
toolName,
|
|
48
|
+
expectedBoundary,
|
|
49
|
+
currentBoundary,
|
|
50
|
+
nextStep: continuation.suggested_next_action,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const DEFAULT_STABLE_CHECKS = 3;
|
|
2
|
+
const DEFAULT_INTERVAL = 200;
|
|
3
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
4
|
+
|
|
5
|
+
function normalizeText(value) {
|
|
6
|
+
return String(value ?? '').trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function defaultSnapshot(page) {
|
|
10
|
+
return page.evaluate(() => {
|
|
11
|
+
const safeText = (el) => (el?.innerText ?? '').trim();
|
|
12
|
+
const main = document.querySelector('main') || document.querySelector('article');
|
|
13
|
+
const mainText = safeText(main);
|
|
14
|
+
const bodyText = safeText(document.body);
|
|
15
|
+
return {
|
|
16
|
+
title: document.title ?? '',
|
|
17
|
+
mainText,
|
|
18
|
+
bodyText,
|
|
19
|
+
readyState: document.readyState,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function extractMainContent(page) {
|
|
25
|
+
const { title, mainText, bodyText } = await defaultSnapshot(page);
|
|
26
|
+
const text = (mainText && mainText.length > 0) ? mainText : bodyText;
|
|
27
|
+
return {
|
|
28
|
+
title,
|
|
29
|
+
text: (text ?? '').trim(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function summarizeExtractedText(text) {
|
|
34
|
+
const normalized = normalizeText(text).replace(/\s+/g, ' ');
|
|
35
|
+
if (!normalized) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sentenceMatch = normalized.match(/^(.+?)[.!?](?:\s|$)/);
|
|
40
|
+
if (sentenceMatch) {
|
|
41
|
+
return sentenceMatch[1].trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toMarkdownDocument({ title, text }) {
|
|
48
|
+
return `# ${title || 'Untitled'}\n\n${text}`.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function waitUntilStable(page, options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
getSnapshot,
|
|
54
|
+
stableChecks = DEFAULT_STABLE_CHECKS,
|
|
55
|
+
interval = DEFAULT_INTERVAL,
|
|
56
|
+
timeout = DEFAULT_TIMEOUT,
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const snapshotter = typeof getSnapshot === 'function'
|
|
60
|
+
? () => getSnapshot(page)
|
|
61
|
+
: () => defaultSnapshot(page);
|
|
62
|
+
|
|
63
|
+
const start = Date.now();
|
|
64
|
+
const history = [];
|
|
65
|
+
let lastSnapshot;
|
|
66
|
+
|
|
67
|
+
while (Date.now() - start < timeout) {
|
|
68
|
+
const snapshot = await snapshotter();
|
|
69
|
+
history.push(snapshot);
|
|
70
|
+
lastSnapshot = snapshot;
|
|
71
|
+
|
|
72
|
+
if (history.length >= stableChecks) {
|
|
73
|
+
const recent = history.slice(-stableChecks);
|
|
74
|
+
const normalized = recent.map((item) => JSON.stringify(item));
|
|
75
|
+
const first = normalized[0];
|
|
76
|
+
if (normalized.every((value) => value === first)) {
|
|
77
|
+
return {
|
|
78
|
+
stable: true,
|
|
79
|
+
attempts: history.length,
|
|
80
|
+
snapshot: recent[0],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (interval > 0) {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
87
|
+
} else {
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
stable: false,
|
|
94
|
+
attempts: history.length,
|
|
95
|
+
snapshot: lastSnapshot,
|
|
96
|
+
};
|
|
97
|
+
}
|