clawmoney 0.17.46 → 0.17.47
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/dist/commands/relay.js +8 -6
- package/dist/relay/pricing.js +7 -0
- package/dist/relay/provider.js +35 -5
- package/dist/relay/upstream/chatgpt-web.d.ts +51 -0
- package/dist/relay/upstream/chatgpt-web.js +114 -0
- package/dist/task/daemon.js +55 -5
- package/dist/task/preflight.d.ts +47 -0
- package/dist/task/preflight.js +515 -0
- package/dist/task/skills/codex/_bnbot.js +3 -0
- package/dist/task/skills/index.d.ts +16 -0
- package/dist/task/skills/index.js +59 -1
- package/dist/task/skills/opencli/_bnbot.d.ts +5 -0
- package/dist/task/skills/opencli/_bnbot.js +21 -0
- package/dist/task/skills/opencli/linkedin-salesnav.d.ts +7 -0
- package/dist/task/skills/opencli/linkedin-salesnav.js +36 -0
- package/dist/task/skills/opencli/platforms.d.ts +4 -0
- package/dist/task/skills/opencli/platforms.js +5 -0
- package/dist/task/skills/opencli/scrapers.d.ts +6 -0
- package/dist/task/skills/opencli/scrapers.js +37 -0
- package/package.json +1 -1
package/dist/commands/relay.js
CHANGED
|
@@ -11,7 +11,7 @@ const LOG_FILE = join(homedir(), ".clawmoney", "relay.log");
|
|
|
11
11
|
export async function relayRegisterCommand(options) {
|
|
12
12
|
const config = requireConfig();
|
|
13
13
|
// Validate CLI type
|
|
14
|
-
const validClis = ["claude", "codex", "gemini", "antigravity"];
|
|
14
|
+
const validClis = ["claude", "codex", "gemini", "antigravity", "chatgpt-web"];
|
|
15
15
|
if (!validClis.includes(options.cli)) {
|
|
16
16
|
console.error(chalk.red(`Invalid CLI type "${options.cli}". Must be one of: ${validClis.join(", ")}`));
|
|
17
17
|
process.exit(1);
|
|
@@ -36,14 +36,16 @@ export async function relayRegisterCommand(options) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
else {
|
|
39
|
-
|
|
39
|
+
// chatgpt-web drives the browser via opencli — there's no "chatgpt-web" binary.
|
|
40
|
+
const probeBin = options.cli === "chatgpt-web" ? "opencli" : options.cli;
|
|
41
|
+
const spinner = ora(`Checking if ${probeBin} is installed...`).start();
|
|
40
42
|
try {
|
|
41
|
-
execSync(`which ${
|
|
42
|
-
spinner.succeed(`${
|
|
43
|
+
execSync(`which ${probeBin}`, { stdio: "pipe" });
|
|
44
|
+
spinner.succeed(`${probeBin} is available`);
|
|
43
45
|
}
|
|
44
46
|
catch {
|
|
45
|
-
spinner.fail(chalk.red(`${
|
|
46
|
-
console.log(chalk.dim(` Make sure ${
|
|
47
|
+
spinner.fail(chalk.red(`${probeBin} is not installed or not in PATH`));
|
|
48
|
+
console.log(chalk.dim(` Make sure ${probeBin} CLI is installed and accessible.`));
|
|
47
49
|
process.exit(1);
|
|
48
50
|
}
|
|
49
51
|
}
|
package/dist/relay/pricing.js
CHANGED
|
@@ -32,6 +32,13 @@ export const API_PRICES = {
|
|
|
32
32
|
// 5 / 5.1 / 5.2-codex families were fully removed that day. Anything
|
|
33
33
|
// below this comment that's deprecated was removed from the CLI-side
|
|
34
34
|
// pricing table so `modelsForCli("codex")` no longer offers them.
|
|
35
|
+
//
|
|
36
|
+
// gpt-5.5 — current Codex CLI default (config.toml `model = "gpt-5.5"`)
|
|
37
|
+
// after the mid-2026 bump. Upgraded ChatGPT accounts now 404 the older
|
|
38
|
+
// 5.4/5.3-codex/5.2 ids with "not supported when using Codex with a
|
|
39
|
+
// ChatGPT account", so gpt-5.5 must be offered. Priced at the 5.4 tier
|
|
40
|
+
// pending an official LiteLLM entry.
|
|
41
|
+
"gpt-5.5": { input: 2.50, output: 15 },
|
|
35
42
|
"gpt-5.4": { input: 2.50, output: 15 },
|
|
36
43
|
"gpt-5.4-mini": { input: 0.75, output: 4.50 },
|
|
37
44
|
"gpt-5.3-codex": { input: 1.75, output: 14 },
|
package/dist/relay/provider.js
CHANGED
|
@@ -6,6 +6,7 @@ import { RelayWsClient } from "./ws-client.js";
|
|
|
6
6
|
import { callClaudeApi, callClaudeApiPassthrough, preflightClaudeApi, getRateGuardSnapshot as getClaudeRateGuardSnapshot, } from "./upstream/claude-api.js";
|
|
7
7
|
import { callCodexApi, callCodexApiPassthrough, preflightCodexApi, getRateGuardSnapshot as getCodexRateGuardSnapshot, } from "./upstream/codex-api.js";
|
|
8
8
|
import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
|
|
9
|
+
import { callChatGPTWeb } from "./upstream/chatgpt-web.js";
|
|
9
10
|
import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
|
|
10
11
|
import { callMinimaxApi, preflightMinimaxApi, getMinimaxRateGuardSnapshot, } from "./upstream/minimax-api.js";
|
|
11
12
|
import { callKimiCodingApi, preflightKimiCodingApi, getKimiCodingRateGuardSnapshot, } from "./upstream/kimi-coding-api.js";
|
|
@@ -221,6 +222,11 @@ function messagesToPrompt(messages) {
|
|
|
221
222
|
const AUTH_ERROR_THRESHOLD = 3;
|
|
222
223
|
const consecutiveAuthErrorsByCli = new Map();
|
|
223
224
|
const cliAuthDisabled = new Set();
|
|
225
|
+
// chatgpt-web: buyer sessions we've already opened a ChatGPT tab for. First
|
|
226
|
+
// turn opens a temporary chat; later turns continue in the same tab so context
|
|
227
|
+
// accumulates. Keyed by cli_session_id (falls back to request_id for stateless
|
|
228
|
+
// single-shots, which just get a fresh temporary chat each time).
|
|
229
|
+
const chatgptWebSessions = new Set();
|
|
224
230
|
const AUTH_BROKEN_PATTERNS = [
|
|
225
231
|
// Anthropic 403: OAuth authentication is currently not allowed for
|
|
226
232
|
// this organization. The new prod signal from 2026-04-15 incident.
|
|
@@ -313,11 +319,15 @@ async function executeRelayRequest(request, config, sendChunk) {
|
|
|
313
319
|
// antigravity → daily-cloudcode-pa). Each handler has its own
|
|
314
320
|
// fingerprint file and rate-guard instance.
|
|
315
321
|
if (cliType === "codex") {
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
322
|
+
// Passthrough ONLY when the Hub forwarded a real Responses API body —
|
|
323
|
+
// i.e. it carries an `input` array (the /v1/responses drop-in path).
|
|
324
|
+
// The OpenAI-compat /v1/chat/completions path forwards a body with
|
|
325
|
+
// `messages` (no `input`), so fall through to template mode, which
|
|
326
|
+
// builds a Responses request from the flattened `prompt`. Without this
|
|
327
|
+
// guard, chat/completions hit passthrough and 400'd with
|
|
328
|
+
// "Passthrough body missing `input` array".
|
|
329
|
+
const pb = request.passthrough_body;
|
|
330
|
+
if (pb && Array.isArray(pb.input) && pb.input.length > 0) {
|
|
321
331
|
parsed = await callCodexApiPassthrough({
|
|
322
332
|
clientBody: request.passthrough_body,
|
|
323
333
|
model,
|
|
@@ -340,6 +350,26 @@ async function executeRelayRequest(request, config, sendChunk) {
|
|
|
340
350
|
maxTokens: max_budget_usd ? undefined : 8192,
|
|
341
351
|
});
|
|
342
352
|
}
|
|
353
|
+
else if (cliType === "chatgpt-web") {
|
|
354
|
+
// Web send/read path — drive chatgpt.com via opencli (temporary chat)
|
|
355
|
+
// instead of the reverse-proxy API. Real-browser route, un-bannable.
|
|
356
|
+
// Per-buyer tab keyed by cli_session_id: first turn opens a temporary
|
|
357
|
+
// chat, later turns continue in the same tab so context accumulates.
|
|
358
|
+
const webKey = cliSessionId || request_id;
|
|
359
|
+
const seenBefore = chatgptWebSessions.has(webKey);
|
|
360
|
+
chatgptWebSessions.add(webKey);
|
|
361
|
+
// Image turn when the buyer asks for an image model (gpt-image-*) — that
|
|
362
|
+
// forces a regular chat + image grab. Text models stay on temporary chat.
|
|
363
|
+
const wantsImage = /image/i.test(model);
|
|
364
|
+
parsed = await callChatGPTWeb({
|
|
365
|
+
prompt,
|
|
366
|
+
model,
|
|
367
|
+
timeout: wantsImage ? 200 : 120,
|
|
368
|
+
sessionKey: webKey,
|
|
369
|
+
continueChat: seenBefore,
|
|
370
|
+
imageOut: wantsImage,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
343
373
|
else if (cliType === "antigravity") {
|
|
344
374
|
parsed = await callAntigravityApi({
|
|
345
375
|
prompt,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
|
|
3
|
+
* (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
|
|
4
|
+
* via opencli's CDP browser automation, waits for the reply, reads it back.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists alongside the codex reverse-proxy path:
|
|
7
|
+
* - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
|
|
8
|
+
* normal use → effectively un-bannable (the reverse-proxy路径 mimics the
|
|
9
|
+
* Codex CLI fingerprint, which is safe but not bullet-proof at scale).
|
|
10
|
+
* - Temporary chat → no history pollution, clean per-session isolation, not
|
|
11
|
+
* used for training.
|
|
12
|
+
* - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
|
|
13
|
+
* conversation; the buyer still gets a standard reply.
|
|
14
|
+
*
|
|
15
|
+
* Requires the provider machine to have `@jackwener/opencli` installed (with the
|
|
16
|
+
* `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
|
|
17
|
+
* opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
|
|
18
|
+
* source runs.
|
|
19
|
+
*/
|
|
20
|
+
import type { ParsedOutput } from "../types.js";
|
|
21
|
+
export interface ChatGPTWebOptions {
|
|
22
|
+
prompt: string;
|
|
23
|
+
model: string;
|
|
24
|
+
/** Max seconds to wait for the assistant reply. */
|
|
25
|
+
timeout?: number;
|
|
26
|
+
/** Continue an existing /c/<id> conversation (multi-turn, non-temporary). */
|
|
27
|
+
conversationId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Continue the CURRENT chat in place (no --new/--temporary). Used for
|
|
30
|
+
* stateful multi-turn inside a temporary chat: the first turn opens the
|
|
31
|
+
* temporary chat, later turns just send into the same tab so ChatGPT
|
|
32
|
+
* accumulates context. Temporary chats have no /c/<id> to resume by URL,
|
|
33
|
+
* so this in-place continuation is the only way to keep their context.
|
|
34
|
+
*/
|
|
35
|
+
continueChat?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Per-buyer browser session key → its own ChatGPT tab. Same key reuses the
|
|
38
|
+
* same tab (multi-turn context); different keys run concurrently in separate
|
|
39
|
+
* tabs. Maps to opencli `--session <key>`.
|
|
40
|
+
*/
|
|
41
|
+
sessionKey?: string;
|
|
42
|
+
/**
|
|
43
|
+
* This turn is expected to produce an image (generate/edit). Forces a regular
|
|
44
|
+
* chat (ChatGPT temporary chat BLOCKS image generation) and waits for + grabs
|
|
45
|
+
* the image. Maps to opencli `--image-out` + `--new` (first turn).
|
|
46
|
+
*/
|
|
47
|
+
imageOut?: boolean;
|
|
48
|
+
/** Local image paths to upload before the prompt (edit the buyer's own image). */
|
|
49
|
+
imagePaths?: string[];
|
|
50
|
+
}
|
|
51
|
+
export declare function callChatGPTWeb(opts: ChatGPTWebOptions): Promise<ParsedOutput>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
|
|
3
|
+
* (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
|
|
4
|
+
* via opencli's CDP browser automation, waits for the reply, reads it back.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists alongside the codex reverse-proxy path:
|
|
7
|
+
* - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
|
|
8
|
+
* normal use → effectively un-bannable (the reverse-proxy路径 mimics the
|
|
9
|
+
* Codex CLI fingerprint, which is safe but not bullet-proof at scale).
|
|
10
|
+
* - Temporary chat → no history pollution, clean per-session isolation, not
|
|
11
|
+
* used for training.
|
|
12
|
+
* - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
|
|
13
|
+
* conversation; the buyer still gets a standard reply.
|
|
14
|
+
*
|
|
15
|
+
* Requires the provider machine to have `@jackwener/opencli` installed (with the
|
|
16
|
+
* `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
|
|
17
|
+
* opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
|
|
18
|
+
* source runs.
|
|
19
|
+
*/
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
const execFileP = promisify(execFile);
|
|
23
|
+
const OPENCLI_BIN = process.env.OPENCLI_BIN || "opencli";
|
|
24
|
+
/** Roughly 4 chars per token — web send/read has no real token counts, so we
|
|
25
|
+
* estimate for billing parity with the API paths. */
|
|
26
|
+
function estimateTokens(text) {
|
|
27
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
28
|
+
}
|
|
29
|
+
export async function callChatGPTWeb(opts) {
|
|
30
|
+
const timeout = opts.timeout ?? 120;
|
|
31
|
+
const args = ["chatgpt", "ask", opts.prompt, "--timeout", String(timeout), "-f", "json"];
|
|
32
|
+
// Turn routing:
|
|
33
|
+
// - continueChat → no flag; opencli's ensureOnChatGPT stays on the current
|
|
34
|
+
// page, so a temporary chat continues in place and keeps its context.
|
|
35
|
+
// - conversationId → resume a saved /c/<id> conversation.
|
|
36
|
+
// - otherwise → open a fresh temporary chat (first turn / single-shot).
|
|
37
|
+
if (opts.continueChat) {
|
|
38
|
+
// intentionally no flag — continue the current chat in place.
|
|
39
|
+
}
|
|
40
|
+
else if (opts.conversationId) {
|
|
41
|
+
args.push("--conversation", opts.conversationId);
|
|
42
|
+
}
|
|
43
|
+
else if (opts.imageOut) {
|
|
44
|
+
// Image generation is blocked in temporary chats → use a regular new chat.
|
|
45
|
+
args.push("--new");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
args.push("--temporary");
|
|
49
|
+
}
|
|
50
|
+
if (opts.imageOut) {
|
|
51
|
+
args.push("--image-out");
|
|
52
|
+
}
|
|
53
|
+
if (opts.imagePaths?.length) {
|
|
54
|
+
args.push("--image", opts.imagePaths.join(","));
|
|
55
|
+
}
|
|
56
|
+
// Per-buyer tab isolation: same key → same tab (context kept), different
|
|
57
|
+
// keys → concurrent tabs.
|
|
58
|
+
if (opts.sessionKey) {
|
|
59
|
+
args.push("--session", opts.sessionKey);
|
|
60
|
+
}
|
|
61
|
+
let stdout = "";
|
|
62
|
+
try {
|
|
63
|
+
const result = await execFileP(OPENCLI_BIN, args, {
|
|
64
|
+
timeout: (timeout + 30) * 1000,
|
|
65
|
+
maxBuffer: 96 * 1024 * 1024, // base64 images can be several MB each
|
|
66
|
+
env: { ...process.env },
|
|
67
|
+
});
|
|
68
|
+
stdout = result.stdout;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const e = err;
|
|
72
|
+
const tail = `${e.stdout ?? ""}${e.stderr ?? ""}`.slice(0, 400);
|
|
73
|
+
throw new Error(`opencli chatgpt ask failed: ${e.message ?? String(err)} ${tail}`);
|
|
74
|
+
}
|
|
75
|
+
// opencli `-f json` prints a JSON array; tolerate any leading banner lines.
|
|
76
|
+
const start = stdout.indexOf("[");
|
|
77
|
+
if (start < 0) {
|
|
78
|
+
throw new Error(`opencli chatgpt ask: no JSON in output: ${stdout.slice(0, 300)}`);
|
|
79
|
+
}
|
|
80
|
+
let rows;
|
|
81
|
+
try {
|
|
82
|
+
rows = JSON.parse(stdout.slice(start));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error(`opencli chatgpt ask: bad JSON: ${stdout.slice(start, start + 300)}`);
|
|
86
|
+
}
|
|
87
|
+
const response = String(rows?.[0]?.response ?? "").trim();
|
|
88
|
+
const images = Array.isArray(rows?.[0]?.images) ? rows[0].images : [];
|
|
89
|
+
if (!response && !images.length) {
|
|
90
|
+
throw new Error(`opencli chatgpt ask empty (stdout=${stdout.length}b, keys=[${Object.keys(rows?.[0] ?? {}).join(",")}], imagesType=${typeof rows?.[0]?.images})`);
|
|
91
|
+
}
|
|
92
|
+
// Carry images back inside the chat.completion content as markdown data
|
|
93
|
+
// URLs — the buyer's front-end renders them directly. (Image turns often
|
|
94
|
+
// have little/no text, so the image IS the answer.)
|
|
95
|
+
let text = response;
|
|
96
|
+
if (images.length) {
|
|
97
|
+
const md = images.map((img, i) => ``).join("\n");
|
|
98
|
+
text = text ? `${text}\n\n${md}` : md;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
sessionId: String(rows?.[0]?.conversationId ?? ""),
|
|
103
|
+
usage: {
|
|
104
|
+
input_tokens: estimateTokens(opts.prompt),
|
|
105
|
+
// Don't token-count the base64 image (it'd be ~375k "tokens"); bill each
|
|
106
|
+
// generated image as a flat token block instead.
|
|
107
|
+
output_tokens: estimateTokens(response) + images.length * 800,
|
|
108
|
+
cache_creation_tokens: 0,
|
|
109
|
+
cache_read_tokens: 0,
|
|
110
|
+
},
|
|
111
|
+
model: opts.model,
|
|
112
|
+
costUsd: 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/dist/task/daemon.js
CHANGED
|
@@ -48,10 +48,12 @@ function installFileLogger() {
|
|
|
48
48
|
console.error = (...args) => logToFile("ERROR", ...args);
|
|
49
49
|
}
|
|
50
50
|
import { TaskWsClient } from "./ws-client.js";
|
|
51
|
-
import { getSkill, listSkills } from "./skills/index.js";
|
|
51
|
+
import { getSkill, listSkills, defaultAdvertiseSkills } from "./skills/index.js";
|
|
52
|
+
import { runPreflight, writePreflightReport } from "./preflight.js";
|
|
52
53
|
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
53
54
|
const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
|
|
54
55
|
const PID_FILE = join(CONFIG_DIR, "task.pid");
|
|
56
|
+
const TASK_STATE_FILE = join(CONFIG_DIR, "task-state.json");
|
|
55
57
|
function loadYamlConfig() {
|
|
56
58
|
if (!existsSync(CONFIG_FILE))
|
|
57
59
|
return {};
|
|
@@ -76,7 +78,10 @@ function loadConfig() {
|
|
|
76
78
|
.split(",")
|
|
77
79
|
.map((s) => s.trim())
|
|
78
80
|
.filter(Boolean);
|
|
79
|
-
|
|
81
|
+
// Default advertise set excludes skills the SpareAPI backend now fetches
|
|
82
|
+
// directly (YC / IndieHackers / Hacker News); an explicit SKILLS= can still
|
|
83
|
+
// opt into them since `supported` below is the full registry.
|
|
84
|
+
const skills = requested.length > 0 ? requested : defaultAdvertiseSkills();
|
|
80
85
|
// Sanity-check: drop anything we can't actually serve so we don't
|
|
81
86
|
// advertise dead skills to the hub.
|
|
82
87
|
const supported = new Set(listSkills());
|
|
@@ -129,6 +134,26 @@ function removePid() {
|
|
|
129
134
|
// ignore
|
|
130
135
|
}
|
|
131
136
|
}
|
|
137
|
+
// Lifecycle phase the desktop app reads to show "service running, checking
|
|
138
|
+
// logins…" during the (possibly slow first-run) preflight, vs "online" once
|
|
139
|
+
// the hub connection is up. Distinct from the pid file, which only says the
|
|
140
|
+
// process exists.
|
|
141
|
+
function writeTaskState(phase) {
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(TASK_STATE_FILE, JSON.stringify({ phase, pid: process.pid, ts: new Date().toISOString() }), "utf-8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* best effort */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function clearTaskState() {
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(TASK_STATE_FILE);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
}
|
|
132
157
|
async function handleTaskRequest(ws, req) {
|
|
133
158
|
const startedAtMs = Date.now();
|
|
134
159
|
const handler = getSkill(req.skill_id);
|
|
@@ -223,7 +248,7 @@ function applySystemProxy() {
|
|
|
223
248
|
console.error(`[task] system proxy detection skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
224
249
|
}
|
|
225
250
|
}
|
|
226
|
-
function main() {
|
|
251
|
+
async function main() {
|
|
227
252
|
// Daemon was started as a script (stdio:"ignore"); from here on, all
|
|
228
253
|
// log lines should land in ~/.clawmoney/task.log.
|
|
229
254
|
installFileLogger();
|
|
@@ -238,12 +263,32 @@ function main() {
|
|
|
238
263
|
console.error("[task] api_key missing — set API_KEY env or run `clawmoney setup`");
|
|
239
264
|
process.exit(1);
|
|
240
265
|
}
|
|
266
|
+
// Mark the service up the moment config checks out — BEFORE the (possibly
|
|
267
|
+
// slow first-run) preflight. Two reasons: the app can show "service running,
|
|
268
|
+
// checking logins…" instead of "not started", and the app's self-heal keys
|
|
269
|
+
// off the pid file, so writing it now stops it from re-spawning us mid-probe.
|
|
270
|
+
writePid();
|
|
271
|
+
writeTaskState("probing");
|
|
272
|
+
// Preflight: probe each platform's external dependency and drop the ones
|
|
273
|
+
// that would fail every task (Codex not installed, X not logged in, the
|
|
274
|
+
// Chrome extension down, …). Writes ~/.clawmoney/preflight.json so the
|
|
275
|
+
// desktop app can surface a home-screen notice. Re-runs every start, so
|
|
276
|
+
// fixing the dependency recovers the platform on the next boot.
|
|
277
|
+
console.log(`[task] preflight on ${config.skills.length} skills…`);
|
|
278
|
+
const { skills: keptSkills, report } = await runPreflight(config.skills);
|
|
279
|
+
writePreflightReport(report);
|
|
280
|
+
config.skills = keptSkills;
|
|
281
|
+
if (report.summary.droppedSkills > 0) {
|
|
282
|
+
console.warn(`[task] preflight dropped ${report.summary.droppedSkills} skill(s) across ` +
|
|
283
|
+
`${report.summary.failed} platform(s): ${report.dropped.join(", ")}`);
|
|
284
|
+
}
|
|
241
285
|
console.log(`[task] starting daemon hub=${config.hub_url} skills=[${config.skills.join(",")}]`);
|
|
242
286
|
if (config.skills.length === 0) {
|
|
243
287
|
console.error("[task] no skills to advertise — refusing to start");
|
|
288
|
+
removePid();
|
|
289
|
+
clearTaskState();
|
|
244
290
|
process.exit(1);
|
|
245
291
|
}
|
|
246
|
-
writePid();
|
|
247
292
|
// Belt-and-suspenders: even if every other handle (WS, heartbeat,
|
|
248
293
|
// reconnect timer) somehow goes away simultaneously, this interval
|
|
249
294
|
// is unref-less and ref-counted into the event loop, so the daemon
|
|
@@ -263,6 +308,7 @@ function main() {
|
|
|
263
308
|
switch (frame.event) {
|
|
264
309
|
case "connected":
|
|
265
310
|
console.log(`[task] connected agent_id=${frame.agent_id} name="${frame.agent_name}"`);
|
|
311
|
+
writeTaskState("online");
|
|
266
312
|
break;
|
|
267
313
|
case "task_request":
|
|
268
314
|
void handleTaskRequest(ws, frame);
|
|
@@ -279,6 +325,7 @@ function main() {
|
|
|
279
325
|
console.log(`[task] ${signal} — shutting down`);
|
|
280
326
|
ws.stop();
|
|
281
327
|
removePid();
|
|
328
|
+
clearTaskState();
|
|
282
329
|
setTimeout(() => process.exit(0), 200);
|
|
283
330
|
};
|
|
284
331
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
@@ -289,5 +336,8 @@ function main() {
|
|
|
289
336
|
// the module is imported (e.g. by src/commands/task.ts which only wants
|
|
290
337
|
// to use readTaskPid / isPidAlive helpers).
|
|
291
338
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
292
|
-
main()
|
|
339
|
+
main().catch((err) => {
|
|
340
|
+
console.error(`[task] fatal: ${err instanceof Error ? err.stack : String(err)}`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
});
|
|
293
343
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type PreflightStatus = "ok" | "failed" | "unknown";
|
|
2
|
+
export interface PlatformPreflight {
|
|
3
|
+
/** Display name surfaced in the app notice. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** "local-app" | "web-login" | "social-login" — drives the app hint copy. */
|
|
6
|
+
category: string;
|
|
7
|
+
status: PreflightStatus;
|
|
8
|
+
/** How many advertised skills sit under this platform prefix. */
|
|
9
|
+
skills: number;
|
|
10
|
+
/** Human-readable failure reason (only when failed/unknown). */
|
|
11
|
+
reason?: string;
|
|
12
|
+
/** Actionable fix shown to the operator. */
|
|
13
|
+
hint?: string;
|
|
14
|
+
/** false = nothing the operator can do (e.g. upstream app dropped the
|
|
15
|
+
* capability); the app keeps it off the notice banner. Default true. */
|
|
16
|
+
actionable?: boolean;
|
|
17
|
+
/** Login page the app can open for the operator to fix a logged-out
|
|
18
|
+
* browser platform in one click. */
|
|
19
|
+
loginUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PreflightReport {
|
|
22
|
+
ts: string;
|
|
23
|
+
/** false when any probed platform failed. */
|
|
24
|
+
ok: boolean;
|
|
25
|
+
summary: {
|
|
26
|
+
checked: number;
|
|
27
|
+
failed: number;
|
|
28
|
+
droppedSkills: number;
|
|
29
|
+
};
|
|
30
|
+
/** Keyed by platform prefix (the segment before the first dot in a skill_id). */
|
|
31
|
+
platforms: Record<string, PlatformPreflight>;
|
|
32
|
+
/** skill_ids removed from the advertise set. */
|
|
33
|
+
dropped: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface PreflightOutcome {
|
|
36
|
+
/** Skills that passed (or were never probed) — the advertise set. */
|
|
37
|
+
skills: string[];
|
|
38
|
+
report: PreflightReport;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Probe every platform that has a registered dependency, drop the failures
|
|
42
|
+
* from the advertise set, and build the report. `unknown` verdicts are
|
|
43
|
+
* kept advertising (conservative) but recorded so the app can hint.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runPreflight(skills: string[]): Promise<PreflightOutcome>;
|
|
46
|
+
/** Persist the verdict for the desktop app to read on its next dashboard load. */
|
|
47
|
+
export declare function writePreflightReport(report: PreflightReport): void;
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider preflight.
|
|
3
|
+
*
|
|
4
|
+
* Before advertising skills to the hub we probe each platform's external
|
|
5
|
+
* dependency — a local desktop app (Codex / ChatGPT), a logged-in browser
|
|
6
|
+
* session (X / 小红书 / 抖音 / ChatGPT Web / Gemini / Flow), or the BNBot
|
|
7
|
+
* Chrome extension link they all share. A skill whose dependency is
|
|
8
|
+
* missing will fail *every* task and burn the provider's reputation, so
|
|
9
|
+
* we drop those platforms from the advertise set instead of letting the
|
|
10
|
+
* hub route work to them.
|
|
11
|
+
*
|
|
12
|
+
* Design choices:
|
|
13
|
+
* - Probes are LIGHT. We never run a real scrape/generation — only the
|
|
14
|
+
* `status` / `whoami` introspection commands bnbot already ships, or a
|
|
15
|
+
* filesystem/env check. The operator explicitly asked us not to hammer
|
|
16
|
+
* the machine on every boot. One exception: Codex SELF-HEALS — a
|
|
17
|
+
* portless/stopped Codex is relaunched with the CDP flag right here
|
|
18
|
+
* (see probeCodex) instead of being reported for manual fixing.
|
|
19
|
+
* - Only platforms with a *deterministic* dependency are probed. Public
|
|
20
|
+
* read surfaces (wiki / google / hn / reddit / youtube …) have no local
|
|
21
|
+
* dependency beyond network, so they are never dropped here — that
|
|
22
|
+
* avoids false-positives taking good platforms offline.
|
|
23
|
+
* - The extension link (`bnbot status`) is the shared gate for every
|
|
24
|
+
* browser-driven platform; we probe it once and reuse the verdict.
|
|
25
|
+
* - Everything re-runs on each daemon start. KeepAlive (launchd) and the
|
|
26
|
+
* desktop app's self-heal both restart the daemon, so installing Codex
|
|
27
|
+
* or logging into x.com recovers the platform on the next boot — no
|
|
28
|
+
* persistent blacklist to clear.
|
|
29
|
+
*
|
|
30
|
+
* The verdict is written to ~/.clawmoney/preflight.json for the desktop
|
|
31
|
+
* app to surface as a home-screen notice + per-card status.
|
|
32
|
+
*/
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
const PREFLIGHT_FILE = join(homedir(), ".clawmoney", "preflight.json");
|
|
38
|
+
// A navigation login-probe costs ~15s and login state rarely flips, so reuse a
|
|
39
|
+
// recent verdict across daemon restarts (KeepAlive / app self-heal) instead of
|
|
40
|
+
// re-probing every browser platform each boot. Local-app probes (codex/chatgpt)
|
|
41
|
+
// are cheap + volatile, so they're always re-run, never cached.
|
|
42
|
+
const CACHE_FRESH_MS = 30 * 60 * 1000;
|
|
43
|
+
// Same resolution order as the skill handlers' bnbot lookup so the probe
|
|
44
|
+
// hits the exact binary the skills will use.
|
|
45
|
+
const BNBOT_CANDIDATES = [
|
|
46
|
+
process.env.BNBOT_CLI,
|
|
47
|
+
"bnbot",
|
|
48
|
+
"/opt/homebrew/bin/bnbot",
|
|
49
|
+
"/usr/local/bin/bnbot",
|
|
50
|
+
].filter((value, index, values) => typeof value === "string" && value.length > 0 && values.indexOf(value) === index);
|
|
51
|
+
function runBnbot(args, timeoutMs) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const attempt = (idx) => {
|
|
54
|
+
const bin = BNBOT_CANDIDATES[idx];
|
|
55
|
+
if (!bin) {
|
|
56
|
+
resolve({ ok: false, stdout: "", stderr: "bnbot not found on PATH", missing: true });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
60
|
+
const e = err;
|
|
61
|
+
// Binary not at this path → try the next candidate.
|
|
62
|
+
if (e && e.code === "ENOENT") {
|
|
63
|
+
attempt(idx + 1);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
resolve({
|
|
67
|
+
ok: !err,
|
|
68
|
+
stdout: stdout ?? "",
|
|
69
|
+
stderr: stderr ?? "",
|
|
70
|
+
missing: false,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
attempt(0);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Pull the first JSON object out of bnbot stdout (it may prefix a banner). */
|
|
78
|
+
function parseJson(stdout) {
|
|
79
|
+
const trimmed = stdout.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(trimmed);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
const start = trimmed.indexOf("{");
|
|
87
|
+
const end = trimmed.lastIndexOf("}");
|
|
88
|
+
if (start >= 0 && end > start) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ── Shared extension gate ────────────────────────────────────────────
|
|
100
|
+
/** `bnbot status` — is the Chrome extension connected to `bnbot serve`?
|
|
101
|
+
* This is the common dependency for every browser-driven platform. */
|
|
102
|
+
async function probeExtensionLink() {
|
|
103
|
+
const r = await runBnbot(["status"], 8000);
|
|
104
|
+
if (r.missing) {
|
|
105
|
+
return {
|
|
106
|
+
status: "failed",
|
|
107
|
+
reason: "未找到 bnbot 命令",
|
|
108
|
+
hint: "安装 @bnbot/cli(provider 机器需要 bnbot)",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// `bnbot status` prints a banner; "connected" only appears when the
|
|
112
|
+
// extension handshake succeeded.
|
|
113
|
+
if (/extension[\s\S]*connected/i.test(r.stdout) || /connected/i.test(r.stdout)) {
|
|
114
|
+
return { status: "ok" };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: "failed",
|
|
118
|
+
reason: "BNBot 浏览器扩展未连接",
|
|
119
|
+
hint: "打开 Chrome,确认已安装 BNBot 扩展且 `bnbot serve` 在运行",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function codexCdpStatus(extraArgs, timeoutMs) {
|
|
123
|
+
const r = await runBnbot(["codex", "status", ...extraArgs], timeoutMs);
|
|
124
|
+
if (r.missing)
|
|
125
|
+
return { missing: true, connected: false, processRunning: false };
|
|
126
|
+
const j = parseJson(r.stdout);
|
|
127
|
+
return {
|
|
128
|
+
missing: false,
|
|
129
|
+
connected: j?.connected === true,
|
|
130
|
+
processRunning: j?.processRunning === true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Quit Codex the way Cmd+Q does (Apple Event). NEVER signal it instead:
|
|
135
|
+
* SIGTERM reads as a crash to macOS, which reopens the app via launchd a
|
|
136
|
+
* few seconds later — without our debug flag — and that zombie then owns
|
|
137
|
+
* the single-instance lock, defeating the relaunch (verified live).
|
|
138
|
+
*/
|
|
139
|
+
function quitCodexGracefully() {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
execFile("osascript", ["-e", 'tell application id "com.openai.codex" to quit'], { timeout: 8000 }, (err) => resolve(!err));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function sleep(ms) {
|
|
145
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
146
|
+
}
|
|
147
|
+
function codexProcessAlive() {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
execFile("pgrep", ["-x", "Codex"], (err) => resolve(!err));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** A CDP-style failure means the app launches fine but never opens the
|
|
153
|
+
* debug port (current Codex builds dropped CDP support entirely), so a
|
|
154
|
+
* fresh quit+relaunch cycle can't fix it — don't churn the operator's
|
|
155
|
+
* Codex again until the backoff expires or the failure mode changes. */
|
|
156
|
+
const HEAL_BACKOFF_MS = 6 * 60 * 60 * 1000;
|
|
157
|
+
function codexHealRecentlyFailed() {
|
|
158
|
+
const prev = readPreviousReport();
|
|
159
|
+
const last = prev?.platforms?.codex;
|
|
160
|
+
if (!last || last.status !== "failed")
|
|
161
|
+
return false;
|
|
162
|
+
// Matches both the post-heal verdict ("…CDP 未就绪") and the backoff
|
|
163
|
+
// verdict itself ("不支持 CDP 调试…") so the backoff renews each boot.
|
|
164
|
+
if (!last.reason?.includes("CDP"))
|
|
165
|
+
return false;
|
|
166
|
+
const ts = Date.parse(prev?.ts ?? "");
|
|
167
|
+
return Number.isFinite(ts) && Date.now() - ts < HEAL_BACKOFF_MS;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Codex is the one dependency we try to SELF-HEAL instead of just reporting:
|
|
171
|
+
* CDP can only be enabled at launch (Chromium limitation), and bnbot already
|
|
172
|
+
* knows how to launch Codex with the debug port (`status --launch`). So a
|
|
173
|
+
* portless instance gets a graceful quit + relaunch here, and "Codex not
|
|
174
|
+
* running" gets a plain launch — the operator only sees a notice when that
|
|
175
|
+
* automation genuinely failed (e.g. current Codex builds ignore the CDP
|
|
176
|
+
* flag altogether — Chromium-148 shell, no remote debugging).
|
|
177
|
+
*/
|
|
178
|
+
async function probeCodex() {
|
|
179
|
+
const first = await codexCdpStatus([], 8000);
|
|
180
|
+
if (first.missing) {
|
|
181
|
+
return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
|
|
182
|
+
}
|
|
183
|
+
if (first.connected)
|
|
184
|
+
return { status: "ok" };
|
|
185
|
+
if (codexHealRecentlyFailed()) {
|
|
186
|
+
return {
|
|
187
|
+
status: "failed",
|
|
188
|
+
reason: "Codex 不支持 CDP 调试(端口 9238),自动修复已暂停重试",
|
|
189
|
+
hint: "当前版本的 Codex 无法开启远程调试,绘图技能暂不可用;等待 bnbot 适配新版 Codex",
|
|
190
|
+
actionable: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (first.processRunning) {
|
|
194
|
+
// Quit the portless instance first: its single-instance lock would
|
|
195
|
+
// swallow a relaunch (args forwarded to the old process, port ignored).
|
|
196
|
+
console.log("[preflight] codex running without CDP — restarting with debug port…");
|
|
197
|
+
await quitCodexGracefully();
|
|
198
|
+
let gone = false;
|
|
199
|
+
for (let i = 0; i < 8; i++) {
|
|
200
|
+
await sleep(1000);
|
|
201
|
+
if (!(await codexProcessAlive())) {
|
|
202
|
+
gone = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!gone) {
|
|
207
|
+
return {
|
|
208
|
+
status: "failed",
|
|
209
|
+
reason: "Codex 已运行但 CDP 未就绪(端口 9238),自动重启未成功",
|
|
210
|
+
hint: "请手动退出 Codex(Cmd+Q),接单守护会自动以调试端口重新拉起",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Not running (or just quit) — launch with the CDP port and re-verify,
|
|
215
|
+
// allowing a slow cold start some runway.
|
|
216
|
+
const launched = await codexCdpStatus(["--launch"], 30000);
|
|
217
|
+
if (launched.connected)
|
|
218
|
+
return { status: "ok" };
|
|
219
|
+
for (let i = 0; i < 6; i++) {
|
|
220
|
+
await sleep(2000);
|
|
221
|
+
const s = await codexCdpStatus([], 8000);
|
|
222
|
+
if (s.connected)
|
|
223
|
+
return { status: "ok" };
|
|
224
|
+
}
|
|
225
|
+
const stillRunning = launched.processRunning || (await codexProcessAlive());
|
|
226
|
+
return {
|
|
227
|
+
status: "failed",
|
|
228
|
+
reason: stillRunning
|
|
229
|
+
? "Codex 已自动拉起但 CDP 未就绪(端口 9238)"
|
|
230
|
+
: "Codex Desktop 自动启动未成功",
|
|
231
|
+
hint: stillRunning
|
|
232
|
+
? "当前版本的 Codex 可能不支持远程调试;绘图技能暂不可用"
|
|
233
|
+
: "安装并打开 Codex.app(运行 `codex app` 可自动下载)",
|
|
234
|
+
// App launches fine but never opens the port → nothing the operator
|
|
235
|
+
// can click to fix. A missing install IS fixable, keep that one loud.
|
|
236
|
+
actionable: !stillRunning,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function probeChatGpt() {
|
|
240
|
+
const r = await runBnbot(["chatgpt", "status"], 8000);
|
|
241
|
+
if (r.missing) {
|
|
242
|
+
return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
|
|
243
|
+
}
|
|
244
|
+
const j = parseJson(r.stdout);
|
|
245
|
+
if (j && j.running === true)
|
|
246
|
+
return { status: "ok" };
|
|
247
|
+
return {
|
|
248
|
+
status: "failed",
|
|
249
|
+
reason: "ChatGPT Desktop 未运行",
|
|
250
|
+
hint: "安装并打开 ChatGPT.app 并保持登录",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** A landed URL matching this means the navigation bounced to a login wall. */
|
|
254
|
+
const DEFAULT_LOGGED_OUT = /login|signin|sign[_-]?in|passport|\/sso\b|accounts\.google\.com|auth\.openai\.com/i;
|
|
255
|
+
/**
|
|
256
|
+
* Real login-state probe — the product's edge. Instead of guessing, drive the
|
|
257
|
+
* extension to the platform's must-login page and read where it actually
|
|
258
|
+
* landed: a logged-out session gets bounced to a login wall, a logged-in one
|
|
259
|
+
* stays on the page. No business request is sent — same signal bnbot's own
|
|
260
|
+
* `checkLoginRedirect` uses, just surfaced as a standalone check.
|
|
261
|
+
*/
|
|
262
|
+
async function probeLoginByNavigation(def) {
|
|
263
|
+
const nav = await runBnbot(["navigate", def.url], def.navTimeoutMs ?? 18000);
|
|
264
|
+
if (nav.missing) {
|
|
265
|
+
return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
|
|
266
|
+
}
|
|
267
|
+
// `debug eval` prints the live tab's URL as `.url` regardless of the
|
|
268
|
+
// expression result — that's the signal we want.
|
|
269
|
+
const ev = await runBnbot(["debug", "eval", "location.href"], 8000);
|
|
270
|
+
const landed = parseJson(ev.stdout);
|
|
271
|
+
const finalUrl = typeof landed?.url === "string" ? landed.url : "";
|
|
272
|
+
if (!finalUrl) {
|
|
273
|
+
return {
|
|
274
|
+
status: "unknown",
|
|
275
|
+
reason: `${def.label} 登录态探测超时`,
|
|
276
|
+
hint: `若 ${def.label} 接单失败,请确认 Chrome 已登录`,
|
|
277
|
+
loginUrl: def.url,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const loggedOut = def.loggedOut || DEFAULT_LOGGED_OUT;
|
|
281
|
+
if (loggedOut.test(finalUrl)) {
|
|
282
|
+
return {
|
|
283
|
+
status: "failed",
|
|
284
|
+
reason: `${def.label} 未登录`,
|
|
285
|
+
hint: `在 Chrome 登录 ${def.label} 后,接单守护重启即自动恢复`,
|
|
286
|
+
loginUrl: def.url,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return { status: "ok" };
|
|
290
|
+
}
|
|
291
|
+
/** Wrap a login-by-navigation probe, gated on the extension link being up
|
|
292
|
+
* (no extension → no browser session to read, so it's a hard fail). */
|
|
293
|
+
function navProbe(def) {
|
|
294
|
+
return async (extensionOk) => {
|
|
295
|
+
if (!extensionOk) {
|
|
296
|
+
return {
|
|
297
|
+
status: "failed",
|
|
298
|
+
reason: "BNBot 浏览器扩展未连接",
|
|
299
|
+
hint: "打开 Chrome 并确认 BNBot 扩展已连接",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return probeLoginByNavigation(def);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** X: extension link is the gate; on top of it we read the active handle
|
|
306
|
+
* via `bnbot x whoami` (no real request — the extension reads the pool
|
|
307
|
+
* window's session). whoami needs a cold pool window so it's slow; on
|
|
308
|
+
* timeout we stay conservative ("unknown", keep advertising) rather than
|
|
309
|
+
* drop a platform that may well be logged in. */
|
|
310
|
+
async function probeX(extensionOk) {
|
|
311
|
+
if (!extensionOk) {
|
|
312
|
+
return {
|
|
313
|
+
status: "failed",
|
|
314
|
+
reason: "BNBot 浏览器扩展未连接",
|
|
315
|
+
hint: "打开 Chrome 并确认 BNBot 扩展已连接",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const r = await runBnbot(["x", "whoami"], 10000);
|
|
319
|
+
const j = parseJson(r.stdout);
|
|
320
|
+
const handle = j && (j.username || j.handle || j.screen_name || j.screenName || j.name);
|
|
321
|
+
if (typeof handle === "string" && handle.trim()) {
|
|
322
|
+
return { status: "ok" };
|
|
323
|
+
}
|
|
324
|
+
// Explicit "not logged in" signal → drop it.
|
|
325
|
+
if (/not\s*logged|no\s*account|logged\s*out|null/i.test(`${r.stdout}\n${r.stderr}`)) {
|
|
326
|
+
return {
|
|
327
|
+
status: "failed",
|
|
328
|
+
reason: "X(Twitter)未登录",
|
|
329
|
+
hint: "在 Chrome 登录 x.com 后重启接单",
|
|
330
|
+
loginUrl: "https://x.com/login",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// whoami timed out / unparseable — fall back to the navigation probe. The
|
|
334
|
+
// x.com/home page bounces to a login wall when logged out, which is more
|
|
335
|
+
// robust than whoami's cold-pool-window read.
|
|
336
|
+
return probeLoginByNavigation({ label: "X / Twitter", url: "https://x.com/home" });
|
|
337
|
+
}
|
|
338
|
+
// ── Registry ─────────────────────────────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Platforms with a deterministic external dependency. Anything not listed
|
|
341
|
+
* here is assumed ready (public read surfaces) and never dropped.
|
|
342
|
+
*
|
|
343
|
+
* Keyed by platform prefix — the segment before the first dot in a
|
|
344
|
+
* skill_id (e.g. "codex.image_generate" → "codex").
|
|
345
|
+
*
|
|
346
|
+
* Login-walled sites use a real navigation probe: open the must-login page,
|
|
347
|
+
* read whether it bounced to a login wall. The `url` must be a page that
|
|
348
|
+
* REQUIRES login (a creator dashboard / app home), so a logged-out session
|
|
349
|
+
* redirects. Add a platform by dropping one line here.
|
|
350
|
+
*/
|
|
351
|
+
const PLATFORM_PROBES = {
|
|
352
|
+
// Local desktop apps — exact, fast probes.
|
|
353
|
+
codex: { label: "Codex 绘图", category: "local-app", probe: probeCodex },
|
|
354
|
+
chatgpt: { label: "ChatGPT", category: "local-app", probe: probeChatGpt },
|
|
355
|
+
// X keeps its dedicated handle read (cheaper, and names the account).
|
|
356
|
+
x: { label: "X / Twitter", category: "social-login", probe: probeX },
|
|
357
|
+
// Login-walled sites — real login-state probe by navigation.
|
|
358
|
+
xhs: {
|
|
359
|
+
label: "小红书", category: "social-login",
|
|
360
|
+
probe: navProbe({ label: "小红书", url: "https://creator.xiaohongshu.com/" }),
|
|
361
|
+
},
|
|
362
|
+
dy: {
|
|
363
|
+
label: "抖音", category: "social-login",
|
|
364
|
+
probe: navProbe({ label: "抖音", url: "https://creator.douyin.com/" }),
|
|
365
|
+
},
|
|
366
|
+
gemini: {
|
|
367
|
+
label: "Gemini 绘图", category: "web-login",
|
|
368
|
+
// Google properties are slow from CN networks — same runway as Flow.
|
|
369
|
+
probe: navProbe({ label: "Gemini", url: "https://gemini.google.com/app", navTimeoutMs: 28000 }),
|
|
370
|
+
},
|
|
371
|
+
flow: {
|
|
372
|
+
label: "Google Flow", category: "web-login",
|
|
373
|
+
probe: navProbe({ label: "Flow", url: "https://labs.google/fx/tools/flow", navTimeoutMs: 28000 }),
|
|
374
|
+
},
|
|
375
|
+
chatgpt_web: {
|
|
376
|
+
label: "ChatGPT 网页", category: "web-login",
|
|
377
|
+
probe: navProbe({ label: "ChatGPT 网页", url: "https://chatgpt.com/" }),
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
/** Whether any probed platform depends on the Chrome extension. */
|
|
381
|
+
function needsExtension(prefixes) {
|
|
382
|
+
for (const p of prefixes) {
|
|
383
|
+
const probe = PLATFORM_PROBES[p];
|
|
384
|
+
if (probe && probe.category !== "local-app")
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
function prefixOf(skillId) {
|
|
390
|
+
const dot = skillId.indexOf(".");
|
|
391
|
+
return dot < 0 ? skillId : skillId.slice(0, dot);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Probe every platform that has a registered dependency, drop the failures
|
|
395
|
+
* from the advertise set, and build the report. `unknown` verdicts are
|
|
396
|
+
* kept advertising (conservative) but recorded so the app can hint.
|
|
397
|
+
*/
|
|
398
|
+
export async function runPreflight(skills) {
|
|
399
|
+
// Count skills per platform prefix so the report can show "3 skills off".
|
|
400
|
+
const skillsByPrefix = new Map();
|
|
401
|
+
for (const s of skills) {
|
|
402
|
+
const p = prefixOf(s);
|
|
403
|
+
const list = skillsByPrefix.get(p);
|
|
404
|
+
if (list)
|
|
405
|
+
list.push(s);
|
|
406
|
+
else
|
|
407
|
+
skillsByPrefix.set(p, [s]);
|
|
408
|
+
}
|
|
409
|
+
const probedPrefixes = new Set([...skillsByPrefix.keys()].filter((p) => PLATFORM_PROBES[p]));
|
|
410
|
+
// Probe the shared extension link once if anything needs it.
|
|
411
|
+
let extensionOk = true;
|
|
412
|
+
if (needsExtension(probedPrefixes)) {
|
|
413
|
+
const ext = await probeExtensionLink();
|
|
414
|
+
extensionOk = ext.status === "ok";
|
|
415
|
+
if (!extensionOk) {
|
|
416
|
+
console.warn(`[preflight] extension link down: ${ext.reason}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const platforms = {};
|
|
420
|
+
const dropped = [];
|
|
421
|
+
// Reuse a recent verdict for browser-login platforms (their nav probe is
|
|
422
|
+
// slow). Only when the extension is up — a down extension invalidates every
|
|
423
|
+
// cached "ok". `unknown` is never cached (it means "retry next time").
|
|
424
|
+
const prev = readPreviousReport();
|
|
425
|
+
const cacheFresh = !!prev &&
|
|
426
|
+
Number.isFinite(Date.parse(prev.ts)) &&
|
|
427
|
+
Date.now() - Date.parse(prev.ts) < CACHE_FRESH_MS;
|
|
428
|
+
// Serial — these talk to one Chrome / one desktop app; parallel probes
|
|
429
|
+
// would contend on the same UI and skew results.
|
|
430
|
+
for (const prefix of probedPrefixes) {
|
|
431
|
+
const def = PLATFORM_PROBES[prefix];
|
|
432
|
+
const prefixSkills = skillsByPrefix.get(prefix) ?? [];
|
|
433
|
+
const cached = prev?.platforms?.[prefix];
|
|
434
|
+
const useCache = def.category !== "local-app" &&
|
|
435
|
+
extensionOk &&
|
|
436
|
+
cacheFresh &&
|
|
437
|
+
!!cached &&
|
|
438
|
+
cached.status !== "unknown";
|
|
439
|
+
let result;
|
|
440
|
+
if (useCache && cached) {
|
|
441
|
+
result = {
|
|
442
|
+
status: cached.status,
|
|
443
|
+
reason: cached.reason,
|
|
444
|
+
hint: cached.hint,
|
|
445
|
+
actionable: cached.actionable,
|
|
446
|
+
loginUrl: cached.loginUrl,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
try {
|
|
451
|
+
result = await def.probe(extensionOk);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
result = {
|
|
455
|
+
status: "unknown",
|
|
456
|
+
reason: `预演异常: ${err instanceof Error ? err.message : String(err)}`,
|
|
457
|
+
hint: "查看 ~/.clawmoney/task.log",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
platforms[prefix] = {
|
|
462
|
+
label: def.label,
|
|
463
|
+
category: def.category,
|
|
464
|
+
status: result.status,
|
|
465
|
+
skills: prefixSkills.length,
|
|
466
|
+
reason: result.reason,
|
|
467
|
+
hint: result.hint,
|
|
468
|
+
actionable: result.actionable,
|
|
469
|
+
loginUrl: result.loginUrl,
|
|
470
|
+
};
|
|
471
|
+
console.log(`[preflight] ${prefix} (${def.label}): ${result.status}` +
|
|
472
|
+
(useCache ? " (cached)" : "") +
|
|
473
|
+
(result.reason ? ` — ${result.reason}` : ""));
|
|
474
|
+
if (result.status === "failed") {
|
|
475
|
+
dropped.push(...prefixSkills);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const droppedSet = new Set(dropped);
|
|
479
|
+
const keptSkills = skills.filter((s) => !droppedSet.has(s));
|
|
480
|
+
const failed = Object.values(platforms).filter((p) => p.status === "failed").length;
|
|
481
|
+
const report = {
|
|
482
|
+
ts: new Date().toISOString(),
|
|
483
|
+
ok: failed === 0,
|
|
484
|
+
summary: {
|
|
485
|
+
checked: probedPrefixes.size,
|
|
486
|
+
failed,
|
|
487
|
+
droppedSkills: dropped.length,
|
|
488
|
+
},
|
|
489
|
+
platforms,
|
|
490
|
+
dropped,
|
|
491
|
+
};
|
|
492
|
+
return { skills: keptSkills, report };
|
|
493
|
+
}
|
|
494
|
+
/** Persist the verdict for the desktop app to read on its next dashboard load. */
|
|
495
|
+
export function writePreflightReport(report) {
|
|
496
|
+
try {
|
|
497
|
+
mkdirSync(join(homedir(), ".clawmoney"), { recursive: true });
|
|
498
|
+
writeFileSync(PREFLIGHT_FILE, JSON.stringify(report, null, 2), "utf-8");
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
console.error(`[preflight] failed to write report: ${err instanceof Error ? err.message : String(err)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Read the last written report so fresh login-state verdicts can be reused
|
|
505
|
+
* across restarts (see CACHE_FRESH_MS). Null when absent/corrupt. */
|
|
506
|
+
function readPreviousReport() {
|
|
507
|
+
try {
|
|
508
|
+
if (!existsSync(PREFLIGHT_FILE))
|
|
509
|
+
return null;
|
|
510
|
+
return JSON.parse(readFileSync(PREFLIGHT_FILE, "utf-8"));
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -19,6 +19,9 @@ export async function bnbotCodexImageGenerate(input) {
|
|
|
19
19
|
String(timeoutS),
|
|
20
20
|
"--response-format",
|
|
21
21
|
input.response_format ?? "b64_json",
|
|
22
|
+
// Self-heal: only acts when Codex is running WITHOUT CDP (quit+relaunch
|
|
23
|
+
// with the debug port); a healthy instance is untouched.
|
|
24
|
+
"--restart",
|
|
22
25
|
];
|
|
23
26
|
if (input.size)
|
|
24
27
|
args.push("--size", input.size);
|
|
@@ -15,4 +15,20 @@ import type { SkillHandler } from "../types.js";
|
|
|
15
15
|
*/
|
|
16
16
|
export declare const SKILL_REGISTRY: Record<string, SkillHandler>;
|
|
17
17
|
export declare function listSkills(): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Skills now served directly by the SpareAPI backend — plain-HTTP public APIs
|
|
20
|
+
* (Y Combinator / IndieHackers / Hacker News) that the gateway fetches itself
|
|
21
|
+
* instead of dispatching to operators. The handlers stay registered, so an
|
|
22
|
+
* explicit `SKILLS=yc.companies,...` can still opt in, but operators DON'T
|
|
23
|
+
* advertise them by default: the hub no longer routes these to operators, and
|
|
24
|
+
* advertising them would just invite redundant single-fetch jobs.
|
|
25
|
+
*
|
|
26
|
+
* Browser-walled platforms (Kickstarter `ks.*` / Indiegogo `igg.*`, behind
|
|
27
|
+
* Cloudflare) are NOT here — they still need a real operator Chrome, so they
|
|
28
|
+
* keep being advertised. Other public read surfaces (wiki / bbc / bloomberg /
|
|
29
|
+
* stackoverflow / v2ex …) also stay, since the backend doesn't fetch them yet.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DIRECT_SERVED_SKILLS: Set<string>;
|
|
32
|
+
/** Skills an operator advertises when the `SKILLS` env var is unset. */
|
|
33
|
+
export declare function defaultAdvertiseSkills(): string[];
|
|
18
34
|
export declare function getSkill(skillId: string): SkillHandler | undefined;
|
|
@@ -143,7 +143,7 @@ import { biliFeedSkill } from "./bilibili/feed.js";
|
|
|
143
143
|
import { biliFeedDetailSkill } from "./bilibili/feed-detail.js";
|
|
144
144
|
import { biliDownloadSkill } from "./bilibili/download.js";
|
|
145
145
|
// OpenCLI public/browser read skills (Google, HN, Wikipedia, etc.)
|
|
146
|
-
import { ggSearchSkill, ggSuggestSkill, ggNewsSkill, ggTrendsSkill, wxmpArticleSearchSkill, wxmpArticleSkill, hnTopSkill, hnNewSkill, hnBestSkill, hnAskSkill, hnShowSkill, hnJobsSkill, hnSearchSkill, hnUserSkill, hnReadSkill, wikiSearchSkill, wikiSummarySkill, wikiRandomSkill, wikiTrendingSkill, wikiPageSkill, yfQuoteSkill, zhSearchSkill, zhHotSkill, zhRecommendSkill, zhQuestionSkill, zhAnswerDetailSkill, zhAnswerCommentsSkill, bbcNewsSkill, bbcTopicSkill, bbgMainSkill, bbgMarketsSkill, bbgEconomicsSkill, bbgIndustriesSkill, bbgTechSkill, bbgPoliticsSkill, bbgBusinessweekSkill, bbgOpinionsSkill, bbgFeedsSkill, bbgArticleSkill, medSearchSkill, medTagSkill, medFeedSkill, medUserSkill, subSearchSkill, subPublicationSkill, subFeedSkill, wbHotSkill, wbSearchSkill, wbFeedSkill, wbUserSkill, wbPostSkill, wbCommentsSkill, kr36NewsSkill, kr36HotSkill, kr36SearchSkill, kr36ArticleSkill, dbSearchSkill, dbMovieHotSkill, dbBookHotSkill, dbTop250Skill, dbPhotosSkill, sfNewsSkill, sfRollingNewsSkill, sfStockSkill, jkFeedSkill, jkSearchSkill, xqSearchSkill, xqHotSkill, xqHotStockSkill, xqStockSkill, xqCommentsSkill, xqKlineSkill, xqEarningsDateSkill, xyzPodcastSkill, xyzPodcastEpisodesSkill, xyzEpisodeSkill, fbSearchSkill, fbProfileSkill, fbEventsSkill, wrSearchSkill, wrRankingSkill, wrBookSkill, ctSearchSkill, ctHotelSuggestSkill, ctHotelSearchSkill, ctFlightSkill, } from "./opencli/platforms.js";
|
|
146
|
+
import { ggSearchSkill, ggSuggestSkill, ggNewsSkill, ggTrendsSkill, wxmpArticleSearchSkill, wxmpArticleSkill, hnTopSkill, hnNewSkill, hnBestSkill, hnAskSkill, hnShowSkill, hnJobsSkill, hnSearchSkill, hnUserSkill, hnReadSkill, wikiSearchSkill, wikiSummarySkill, wikiRandomSkill, wikiTrendingSkill, wikiPageSkill, yfQuoteSkill, zhSearchSkill, zhHotSkill, zhRecommendSkill, zhQuestionSkill, zhAnswerDetailSkill, zhAnswerCommentsSkill, bbcNewsSkill, bbcTopicSkill, bbgMainSkill, bbgMarketsSkill, bbgEconomicsSkill, bbgIndustriesSkill, bbgTechSkill, bbgPoliticsSkill, bbgBusinessweekSkill, bbgOpinionsSkill, bbgFeedsSkill, bbgArticleSkill, medSearchSkill, medTagSkill, medFeedSkill, medUserSkill, subSearchSkill, subPublicationSkill, subFeedSkill, wbHotSkill, wbSearchSkill, wbFeedSkill, wbUserSkill, wbPostSkill, wbCommentsSkill, kr36NewsSkill, kr36HotSkill, kr36SearchSkill, kr36ArticleSkill, dbSearchSkill, dbMovieHotSkill, dbBookHotSkill, dbTop250Skill, dbPhotosSkill, sfNewsSkill, sfRollingNewsSkill, sfStockSkill, jkFeedSkill, jkSearchSkill, xqSearchSkill, xqHotSkill, xqHotStockSkill, xqStockSkill, xqCommentsSkill, xqKlineSkill, xqEarningsDateSkill, xyzPodcastSkill, xyzPodcastEpisodesSkill, xyzEpisodeSkill, fbSearchSkill, fbProfileSkill, fbEventsSkill, wrSearchSkill, wrRankingSkill, wrBookSkill, ctSearchSkill, ctHotelSuggestSkill, ctHotelSearchSkill, ctFlightSkill, ycCompaniesSkill, ihProductsSkill, ksDiscoverSkill, igExploreSkill, } from "./opencli/platforms.js";
|
|
147
147
|
// Codex Desktop generation skills
|
|
148
148
|
import { codexImageGenerateSkill } from "./codex/image-generate.js";
|
|
149
149
|
// ChatGPT Desktop skills
|
|
@@ -155,6 +155,10 @@ import { geminiImageGenerateSkill } from "./gemini/image-generate.js";
|
|
|
155
155
|
// Google Labs Flow skills
|
|
156
156
|
import { flowVideoGenerateSkill } from "./flow/video-generate.js";
|
|
157
157
|
import { flowImageGenerateSkill } from "./flow/image-generate.js";
|
|
158
|
+
// Apify-equivalent scrapers — Amazon / Google Maps / Web Scraper / web read.
|
|
159
|
+
import { amazonProductSkill, amazonOfferSkill, amazonSearchSkill, ggMapsSkill, webScrapeSkill, webReadSkill, } from "./opencli/scrapers.js";
|
|
160
|
+
// LinkedIn lead-gen / Sales Navigator (read-only, queries others' public data).
|
|
161
|
+
import { liPeopleSearchSkill, liSalesnavSearchSkill, liProfileSkill, liProfileExperienceSkill, liProfileProjectsSkill, liPostsSkill, liJobDetailSkill, } from "./opencli/linkedin-salesnav.js";
|
|
158
162
|
// `_unimplemented.ts` kept for future stub skills; not used in the
|
|
159
163
|
// registry today.
|
|
160
164
|
/**
|
|
@@ -356,6 +360,10 @@ export const SKILL_REGISTRY = {
|
|
|
356
360
|
"hn.search": hnSearchSkill,
|
|
357
361
|
"hn.user": hnUserSkill,
|
|
358
362
|
"hn.read": hnReadSkill,
|
|
363
|
+
"yc.companies": ycCompaniesSkill,
|
|
364
|
+
"ih.products": ihProductsSkill,
|
|
365
|
+
"ks.discover": ksDiscoverSkill,
|
|
366
|
+
"igg.explore": igExploreSkill,
|
|
359
367
|
"wiki.search": wikiSearchSkill,
|
|
360
368
|
"wiki.summary": wikiSummarySkill,
|
|
361
369
|
"wiki.random": wikiRandomSkill,
|
|
@@ -443,10 +451,60 @@ export const SKILL_REGISTRY = {
|
|
|
443
451
|
// Google Labs Flow — provider drives labs.google/fx/tools/flow via bnbot.
|
|
444
452
|
"flow.video_generate": flowVideoGenerateSkill,
|
|
445
453
|
"flow.image_generate": flowImageGenerateSkill,
|
|
454
|
+
// Apify-equivalent scrapers — run in the operator's real logged-in Chrome
|
|
455
|
+
// (residential IP + login state). Skill ids match hub routing:
|
|
456
|
+
// /amazon/product → amazon.product, /web/scrape → web.scrape,
|
|
457
|
+
// /web/read → web.read, /google/maps → gg.maps.
|
|
458
|
+
"amazon.product": amazonProductSkill,
|
|
459
|
+
"amazon.offer": amazonOfferSkill,
|
|
460
|
+
"amazon.search": amazonSearchSkill,
|
|
461
|
+
"gg.maps": ggMapsSkill,
|
|
462
|
+
"web.scrape": webScrapeSkill,
|
|
463
|
+
"web.read": webReadSkill,
|
|
464
|
+
// LinkedIn lead-gen / Sales Navigator — read-only, queries others' public
|
|
465
|
+
// data. /linkedin/{action} → li.{action} via the hub catalog router.
|
|
466
|
+
// salesnav_search needs a Sales Navigator subscription on the provider.
|
|
467
|
+
"li.people_search": liPeopleSearchSkill,
|
|
468
|
+
"li.salesnav_search": liSalesnavSearchSkill,
|
|
469
|
+
"li.profile": liProfileSkill,
|
|
470
|
+
"li.profile_experience": liProfileExperienceSkill,
|
|
471
|
+
"li.profile_projects": liProfileProjectsSkill,
|
|
472
|
+
"li.posts": liPostsSkill,
|
|
473
|
+
"li.job_detail": liJobDetailSkill,
|
|
446
474
|
};
|
|
447
475
|
export function listSkills() {
|
|
448
476
|
return Object.keys(SKILL_REGISTRY);
|
|
449
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Skills now served directly by the SpareAPI backend — plain-HTTP public APIs
|
|
480
|
+
* (Y Combinator / IndieHackers / Hacker News) that the gateway fetches itself
|
|
481
|
+
* instead of dispatching to operators. The handlers stay registered, so an
|
|
482
|
+
* explicit `SKILLS=yc.companies,...` can still opt in, but operators DON'T
|
|
483
|
+
* advertise them by default: the hub no longer routes these to operators, and
|
|
484
|
+
* advertising them would just invite redundant single-fetch jobs.
|
|
485
|
+
*
|
|
486
|
+
* Browser-walled platforms (Kickstarter `ks.*` / Indiegogo `igg.*`, behind
|
|
487
|
+
* Cloudflare) are NOT here — they still need a real operator Chrome, so they
|
|
488
|
+
* keep being advertised. Other public read surfaces (wiki / bbc / bloomberg /
|
|
489
|
+
* stackoverflow / v2ex …) also stay, since the backend doesn't fetch them yet.
|
|
490
|
+
*/
|
|
491
|
+
export const DIRECT_SERVED_SKILLS = new Set([
|
|
492
|
+
"yc.companies",
|
|
493
|
+
"ih.products",
|
|
494
|
+
"hn.top",
|
|
495
|
+
"hn.new",
|
|
496
|
+
"hn.best",
|
|
497
|
+
"hn.ask",
|
|
498
|
+
"hn.show",
|
|
499
|
+
"hn.jobs",
|
|
500
|
+
"hn.search",
|
|
501
|
+
"hn.user",
|
|
502
|
+
"hn.read",
|
|
503
|
+
]);
|
|
504
|
+
/** Skills an operator advertises when the `SKILLS` env var is unset. */
|
|
505
|
+
export function defaultAdvertiseSkills() {
|
|
506
|
+
return Object.keys(SKILL_REGISTRY).filter((s) => !DIRECT_SERVED_SKILLS.has(s));
|
|
507
|
+
}
|
|
450
508
|
export function getSkill(skillId) {
|
|
451
509
|
return SKILL_REGISTRY[skillId];
|
|
452
510
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export type FlagValue = string | number | boolean | undefined;
|
|
2
2
|
export declare function bnbotCommand(base: string[], positional?: string[], flags?: Record<string, FlagValue>): Promise<unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Run an opencli command that emits raw text (e.g. `web read --stdout true`
|
|
5
|
+
* prints Markdown, not JSON). Returns stdout verbatim — no JSON.parse.
|
|
6
|
+
*/
|
|
7
|
+
export declare function opencliText(args: string[]): Promise<string>;
|
|
3
8
|
export declare function opencliCommand(base: string[], positional?: string[], flags?: Record<string, FlagValue>): Promise<unknown>;
|
|
@@ -37,6 +37,27 @@ export async function bnbotCommand(base, positional = [], flags = {}) {
|
|
|
37
37
|
throw err;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Run an opencli command that emits raw text (e.g. `web read --stdout true`
|
|
42
|
+
* prints Markdown, not JSON). Returns stdout verbatim — no JSON.parse.
|
|
43
|
+
*/
|
|
44
|
+
export async function opencliText(args) {
|
|
45
|
+
const rawBin = process.env.OPENCLI_CLI || "opencli";
|
|
46
|
+
const bin = rawBin.endsWith(".js") ? process.execPath : rawBin;
|
|
47
|
+
const finalArgs = rawBin.endsWith(".js") ? [rawBin, ...args] : args;
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await exec(bin, finalArgs, { maxBuffer: MAX_BUFFER, timeout: TIMEOUT_MS });
|
|
50
|
+
if (!stdout.trim())
|
|
51
|
+
throw new Error("opencli returned empty stdout");
|
|
52
|
+
return stdout;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const e = err;
|
|
56
|
+
if (e.stderr && e.stderr.trim())
|
|
57
|
+
throw new Error(`opencli failed: ${e.stderr.trim()}`);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
40
61
|
export async function opencliCommand(base, positional = [], flags = {}) {
|
|
41
62
|
const args = [...base, ...positional];
|
|
42
63
|
for (const [name, value] of Object.entries(flags)) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const liPeopleSearchSkill: import("../../types.js").SkillHandler;
|
|
2
|
+
export declare const liSalesnavSearchSkill: import("../../types.js").SkillHandler;
|
|
3
|
+
export declare const liProfileSkill: import("../../types.js").SkillHandler;
|
|
4
|
+
export declare const liProfileExperienceSkill: import("../../types.js").SkillHandler;
|
|
5
|
+
export declare const liProfileProjectsSkill: import("../../types.js").SkillHandler;
|
|
6
|
+
export declare const liPostsSkill: import("../../types.js").SkillHandler;
|
|
7
|
+
export declare const liJobDetailSkill: import("../../types.js").SkillHandler;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn lead-gen / Sales Navigator skills — the highest-value, hardest-to-
|
|
3
|
+
* scrape surface (Apify charges $3-10/1K and data-center IPs get blocked fast).
|
|
4
|
+
* These run in the operator's real logged-in Chrome via **bnbot** (extension),
|
|
5
|
+
* so they carry a genuine session and residential IP.
|
|
6
|
+
*
|
|
7
|
+
* Migrated from opencli → bnbot (2026-06-07). bnbot takes url/keywords as a
|
|
8
|
+
* positional arg (not a --profile-url flag), and the profile command is
|
|
9
|
+
* `linkedin profile` (opencli called it `profile-read`).
|
|
10
|
+
*
|
|
11
|
+
* SAFETY: only commands that query OTHER people / public data are exposed.
|
|
12
|
+
* The operator's OWN private surface and all write ops are NOT registered.
|
|
13
|
+
*
|
|
14
|
+
* Skill ids are `li.{action}`; the hub's catalog router maps
|
|
15
|
+
* /linkedin/{action} → li.{action} automatically (no hub change needed).
|
|
16
|
+
*/
|
|
17
|
+
import { bnbotCommand } from "./_bnbot.js";
|
|
18
|
+
import { makeOpenCliSkill, num, reqStr } from "./_skill.js";
|
|
19
|
+
const limit = (i, fb) => num(i, ["limit", "count"]) ?? fb;
|
|
20
|
+
const profileUrl = (i) => reqStr(i, ["profile_url", "profileUrl", "url"], "profile_url");
|
|
21
|
+
const keywords = (i) => reqStr(i, ["query", "keywords", "keyword", "q"], "query");
|
|
22
|
+
// People search — B2B lead-gen core (standard LinkedIn search).
|
|
23
|
+
export const liPeopleSearchSkill = makeOpenCliSkill("linkedin people-search", (i) => bnbotCommand(["linkedin", "people-search"], [keywords(i)], { limit: limit(i) }));
|
|
24
|
+
// Sales Navigator search — premium lead-gen. Requires the provider account to
|
|
25
|
+
// have a Sales Navigator subscription.
|
|
26
|
+
export const liSalesnavSearchSkill = makeOpenCliSkill("linkedin salesnav-search", (i) => bnbotCommand(["linkedin", "salesnav-search"], [keywords(i)], { limit: limit(i) }));
|
|
27
|
+
// Profile read — full profile of a target person (bnbot: `linkedin profile <url>`).
|
|
28
|
+
export const liProfileSkill = makeOpenCliSkill("linkedin profile", (i) => bnbotCommand(["linkedin", "profile"], [profileUrl(i)]));
|
|
29
|
+
// A target's work experience.
|
|
30
|
+
export const liProfileExperienceSkill = makeOpenCliSkill("linkedin profile-experience", (i) => bnbotCommand(["linkedin", "profile-experience"], [profileUrl(i)]));
|
|
31
|
+
// A target's projects.
|
|
32
|
+
export const liProfileProjectsSkill = makeOpenCliSkill("linkedin profile-projects", (i) => bnbotCommand(["linkedin", "profile-projects"], [profileUrl(i)]));
|
|
33
|
+
// A target's recent posts.
|
|
34
|
+
export const liPostsSkill = makeOpenCliSkill("linkedin posts", (i) => bnbotCommand(["linkedin", "posts"], [profileUrl(i)], { limit: limit(i) }));
|
|
35
|
+
// Job posting detail.
|
|
36
|
+
export const liJobDetailSkill = makeOpenCliSkill("linkedin job-detail", (i) => bnbotCommand(["linkedin", "job-detail"], [reqStr(i, ["job_url", "jobUrl", "url"], "job_url")]));
|
|
@@ -13,6 +13,10 @@ export declare const hnJobsSkill: import("../../types.js").SkillHandler;
|
|
|
13
13
|
export declare const hnSearchSkill: import("../../types.js").SkillHandler;
|
|
14
14
|
export declare const hnUserSkill: import("../../types.js").SkillHandler;
|
|
15
15
|
export declare const hnReadSkill: import("../../types.js").SkillHandler;
|
|
16
|
+
export declare const ycCompaniesSkill: import("../../types.js").SkillHandler;
|
|
17
|
+
export declare const ihProductsSkill: import("../../types.js").SkillHandler;
|
|
18
|
+
export declare const ksDiscoverSkill: import("../../types.js").SkillHandler;
|
|
19
|
+
export declare const igExploreSkill: import("../../types.js").SkillHandler;
|
|
16
20
|
export declare const wikiSearchSkill: import("../../types.js").SkillHandler;
|
|
17
21
|
export declare const wikiSummarySkill: import("../../types.js").SkillHandler;
|
|
18
22
|
export declare const wikiRandomSkill: import("../../types.js").SkillHandler;
|
|
@@ -41,6 +41,11 @@ export const hnReadSkill = makeOpenCliSkill("hackernews read", (i) => bnbotComma
|
|
|
41
41
|
replies: num(i, ["replies"]),
|
|
42
42
|
"max-length": num(i, ["maxLength", "max_length"]),
|
|
43
43
|
}));
|
|
44
|
+
// ── Lead-gen sources (SpareAPI customer acquisition: YC / IH / KS / IGG) ──
|
|
45
|
+
export const ycCompaniesSkill = makeOpenCliSkill("ycombinator companies", (i) => bnbotCommand(["yc", "companies"], [], { limit: num(i, ["limit", "count"]), batches: str(i, ["batches"]) }));
|
|
46
|
+
export const ihProductsSkill = makeOpenCliSkill("indiehackers products", (i) => bnbotCommand(["indiehackers", "products"], [], { limit: num(i, ["limit", "count"]) }));
|
|
47
|
+
export const ksDiscoverSkill = makeOpenCliSkill("kickstarter discover", (i) => bnbotCommand(["kickstarter", "discover"], [], { limit: num(i, ["limit", "count"]) }));
|
|
48
|
+
export const igExploreSkill = makeOpenCliSkill("indiegogo explore", (i) => bnbotCommand(["indiegogo", "explore"], [], { limit: num(i, ["limit", "count"]) }));
|
|
44
49
|
export const wikiSearchSkill = makeOpenCliSkill("wikipedia search", (i) => bnbotCommand(["wikipedia", "search"], [query(i)], { limit: limit(i), lang: str(i, ["lang", "language"]) }));
|
|
45
50
|
export const wikiSummarySkill = makeOpenCliSkill("wikipedia summary", (i) => bnbotCommand(["wikipedia", "summary"], [reqStr(i, ["title", "page"], "title")], { lang: str(i, ["lang", "language"]) }));
|
|
46
51
|
export const wikiRandomSkill = makeOpenCliSkill("wikipedia random", (i) => bnbotCommand(["wikipedia", "random"], [], { lang: str(i, ["lang", "language"]) }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const amazonProductSkill: import("../../types.js").SkillHandler;
|
|
2
|
+
export declare const amazonOfferSkill: import("../../types.js").SkillHandler;
|
|
3
|
+
export declare const amazonSearchSkill: import("../../types.js").SkillHandler;
|
|
4
|
+
export declare const ggMapsSkill: import("../../types.js").SkillHandler;
|
|
5
|
+
export declare const webScrapeSkill: import("../../types.js").SkillHandler;
|
|
6
|
+
export declare const webReadSkill: import("../../types.js").SkillHandler;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apify-equivalent scraper skills — Amazon, Google Maps, generic Web Scraper,
|
|
3
|
+
* and Website Content Crawler. These bridge the operator's **bnbot** commands
|
|
4
|
+
* (which run in the operator's real logged-in Chrome via the extension, so they
|
|
5
|
+
* see residential IP + login state) to the SpareAPI skill surface, letting
|
|
6
|
+
* buyers call them like any RapidAPI actor.
|
|
7
|
+
*
|
|
8
|
+
* Migrated from opencli → bnbot (2026-06-07). Note bnbot takes url/input as a
|
|
9
|
+
* positional arg, not a --url flag like opencli did.
|
|
10
|
+
*
|
|
11
|
+
* Skill ids match the hub's routing:
|
|
12
|
+
* /amazon/product → amazon.product, /amazon/search → amazon.search,
|
|
13
|
+
* /web/scrape → web.scrape, /web/read → web.read, /google/maps → gg.maps.
|
|
14
|
+
*/
|
|
15
|
+
import { bnbotCommand, opencliCommand } from "./_bnbot.js";
|
|
16
|
+
import { makeOpenCliSkill, num, reqStr, str } from "./_skill.js";
|
|
17
|
+
const limit = (i, fallback) => num(i, ["limit", "count"]) ?? fallback;
|
|
18
|
+
// --- Amazon (Apify Amazon Scraper equivalent) ---
|
|
19
|
+
export const amazonProductSkill = makeOpenCliSkill("amazon product", (i) => bnbotCommand(["amazon", "product"], [reqStr(i, ["input", "asin", "url", "query"], "input")]));
|
|
20
|
+
// bnbot has no `amazon offer` yet — keep this one on opencli until migrated.
|
|
21
|
+
export const amazonOfferSkill = makeOpenCliSkill("amazon offer", (i) => opencliCommand(["amazon", "offer"], [reqStr(i, ["input", "asin", "url"], "input")]));
|
|
22
|
+
export const amazonSearchSkill = makeOpenCliSkill("amazon search", (i) => bnbotCommand(["amazon", "search"], [reqStr(i, ["query", "keyword", "q"], "query")], { limit: limit(i) }));
|
|
23
|
+
// --- Google Maps (Apify Google Maps Scraper equivalent — their largest, 361K) ---
|
|
24
|
+
export const ggMapsSkill = makeOpenCliSkill("google maps", (i) => bnbotCommand(["google", "maps"], [reqStr(i, ["query", "keyword", "q"], "query")], { limit: limit(i) }));
|
|
25
|
+
// --- Web Scraper (Apify Web Scraper equivalent — URL + CSS selectors → JSON) ---
|
|
26
|
+
// bnbot: `web scrape <url> --selectors <json> --container <sel>`.
|
|
27
|
+
export const webScrapeSkill = makeOpenCliSkill("web scrape", (i) => {
|
|
28
|
+
const selectors = i.selectors;
|
|
29
|
+
return bnbotCommand(["web", "scrape"], [reqStr(i, ["url"], "url")], {
|
|
30
|
+
selectors: typeof selectors === "string" ? selectors : JSON.stringify(selectors ?? {}),
|
|
31
|
+
container: str(i, ["container"]),
|
|
32
|
+
wait: num(i, ["wait"]),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// --- Website Content Crawler (Apify equivalent — any page → main content) ---
|
|
36
|
+
// bnbot: `web read <url>` returns { url, title, text } JSON directly.
|
|
37
|
+
export const webReadSkill = makeOpenCliSkill("web read", (i) => bnbotCommand(["web", "read"], [reqStr(i, ["url"], "url")]));
|