clawmoney 0.8.0 → 0.8.2
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/hub.d.ts +14 -0
- package/dist/commands/hub.js +166 -0
- package/dist/hub/daemon.d.ts +7 -0
- package/dist/hub/daemon.js +18 -0
- package/dist/hub/dedup.d.ts +4 -0
- package/dist/hub/dedup.js +35 -0
- package/dist/hub/executor.d.ts +13 -0
- package/dist/hub/executor.js +164 -0
- package/dist/hub/logger.d.ts +5 -0
- package/dist/hub/logger.js +47 -0
- package/dist/hub/media.d.ts +11 -0
- package/dist/hub/media.js +70 -0
- package/dist/hub/poller.d.ts +14 -0
- package/dist/hub/poller.js +66 -0
- package/dist/hub/provider.d.ts +4 -0
- package/dist/hub/provider.js +163 -0
- package/dist/hub/types.d.ts +63 -0
- package/dist/hub/types.js +2 -0
- package/dist/hub/ws-client.d.ts +23 -0
- package/dist/hub/ws-client.js +123 -0
- package/dist/index.js +70 -0
- package/package.json +3 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function hubStartCommand(options: {
|
|
2
|
+
cli?: string;
|
|
3
|
+
}): Promise<void>;
|
|
4
|
+
export declare function hubStopCommand(): Promise<void>;
|
|
5
|
+
export declare function hubStatusCommand(): Promise<void>;
|
|
6
|
+
interface RegisterOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
category: string;
|
|
9
|
+
description: string;
|
|
10
|
+
price: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function hubRegisterCommand(options: RegisterOptions): Promise<void>;
|
|
13
|
+
export declare function hubSkillsCommand(): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import { requireConfig } from "../utils/config.js";
|
|
7
|
+
import { apiGet, apiPost } from "../utils/api.js";
|
|
8
|
+
import { readPid, isPidAlive, removePid } from "../hub/provider.js";
|
|
9
|
+
const LOG_FILE = join(homedir(), ".clawmoney", "provider.log");
|
|
10
|
+
// ── hub start ──
|
|
11
|
+
export async function hubStartCommand(options) {
|
|
12
|
+
const config = requireConfig();
|
|
13
|
+
// Check if already running
|
|
14
|
+
const existingPid = readPid();
|
|
15
|
+
if (existingPid && isPidAlive(existingPid)) {
|
|
16
|
+
console.log(chalk.yellow(`Hub Provider is already running (PID ${existingPid}). Use "clawmoney hub stop" first.`));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const spinner = ora("Starting Hub Provider...").start();
|
|
20
|
+
try {
|
|
21
|
+
// Resolve daemon script path relative to this file's directory
|
|
22
|
+
// Works for both compiled (dist/commands/hub.js) and dev (src/commands/hub.ts)
|
|
23
|
+
const thisDir = import.meta.url.replace("file://", "").replace(/\/[^/]+$/, "");
|
|
24
|
+
const parentDir = thisDir.replace(/\/[^/]+$/, "");
|
|
25
|
+
const daemonScript = join(parentDir, "hub", "daemon.js");
|
|
26
|
+
const args = [daemonScript];
|
|
27
|
+
if (options.cli) {
|
|
28
|
+
args.push("--cli", options.cli);
|
|
29
|
+
}
|
|
30
|
+
const child = spawn(process.execPath, args, {
|
|
31
|
+
stdio: "ignore",
|
|
32
|
+
detached: true,
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
CLAWMONEY_DAEMON: "1",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
child.unref();
|
|
39
|
+
// Give the daemon a moment to start and write PID
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
41
|
+
const pid = readPid();
|
|
42
|
+
if (pid && isPidAlive(pid)) {
|
|
43
|
+
spinner.succeed(chalk.green(`Hub Provider started (PID ${pid})`));
|
|
44
|
+
console.log(chalk.dim(` Log file: ${LOG_FILE}`));
|
|
45
|
+
console.log(chalk.dim(` CLI command: ${options.cli || "claude"}`));
|
|
46
|
+
console.log(chalk.dim(` API key: ${config.api_key.slice(0, 8)}...`));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
spinner.fail(chalk.red("Failed to start Hub Provider. Check logs at: " + LOG_FILE));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
spinner.fail(chalk.red("Failed to start Hub Provider"));
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── hub stop ──
|
|
59
|
+
export async function hubStopCommand() {
|
|
60
|
+
const pid = readPid();
|
|
61
|
+
if (!pid) {
|
|
62
|
+
console.log(chalk.dim("Hub Provider is not running (no PID file)."));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!isPidAlive(pid)) {
|
|
66
|
+
console.log(chalk.dim(`Hub Provider PID ${pid} is not alive. Cleaning up PID file.`));
|
|
67
|
+
removePid();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, "SIGTERM");
|
|
72
|
+
console.log(chalk.green(`Hub Provider stopped (PID ${pid}).`));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error(chalk.red(`Failed to stop process ${pid}:`), err.message);
|
|
76
|
+
}
|
|
77
|
+
// Wait briefly for cleanup, then ensure PID file is removed
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
79
|
+
removePid();
|
|
80
|
+
}
|
|
81
|
+
// ── hub status ──
|
|
82
|
+
export async function hubStatusCommand() {
|
|
83
|
+
const pid = readPid();
|
|
84
|
+
if (!pid) {
|
|
85
|
+
console.log(chalk.dim("Hub Provider is not running."));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (isPidAlive(pid)) {
|
|
89
|
+
console.log(chalk.green(`Hub Provider is running (PID ${pid}).`));
|
|
90
|
+
console.log(chalk.dim(` Log file: ${LOG_FILE}`));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.log(chalk.yellow(`Hub Provider PID ${pid} is not alive (stale PID file).`));
|
|
94
|
+
removePid();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function hubRegisterCommand(options) {
|
|
98
|
+
const config = requireConfig();
|
|
99
|
+
const price = parseFloat(options.price);
|
|
100
|
+
if (isNaN(price) || price < 0) {
|
|
101
|
+
console.error(chalk.red("Invalid price. Must be a non-negative number."));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const spinner = ora("Registering skill...").start();
|
|
105
|
+
try {
|
|
106
|
+
const resp = await apiPost("/api/v1/hub/skills", {
|
|
107
|
+
skill_name: options.name,
|
|
108
|
+
category: options.category,
|
|
109
|
+
description: options.description,
|
|
110
|
+
price: price,
|
|
111
|
+
}, config.api_key);
|
|
112
|
+
if (!resp.ok) {
|
|
113
|
+
const raw = resp.data && typeof resp.data === "object" && "detail" in resp.data
|
|
114
|
+
? resp.data.detail
|
|
115
|
+
: resp.data;
|
|
116
|
+
const detail = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
117
|
+
spinner.fail(chalk.red(`Failed to register skill (${resp.status}): ${detail}`));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
spinner.succeed(chalk.green("Skill registered successfully!"));
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(` ${chalk.bold("Name:")} ${options.name}`);
|
|
123
|
+
console.log(` ${chalk.bold("Category:")} ${options.category}`);
|
|
124
|
+
console.log(` ${chalk.bold("Price:")} $${price.toFixed(2)}/call`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
spinner.fail(chalk.red("Failed to register skill"));
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function hubSkillsCommand() {
|
|
132
|
+
const config = requireConfig();
|
|
133
|
+
const spinner = ora("Fetching skills...").start();
|
|
134
|
+
try {
|
|
135
|
+
const resp = await apiGet("/api/v1/hub/skills/mine", config.api_key);
|
|
136
|
+
if (!resp.ok) {
|
|
137
|
+
spinner.fail(chalk.red(`Failed to fetch skills (${resp.status})`));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const skills = Array.isArray(resp.data)
|
|
141
|
+
? resp.data
|
|
142
|
+
: resp.data.data ?? [];
|
|
143
|
+
spinner.succeed(`My Skills (${skills.length})`);
|
|
144
|
+
console.log("");
|
|
145
|
+
if (skills.length === 0) {
|
|
146
|
+
console.log(chalk.dim(' No skills registered. Use "clawmoney hub register" to add one.'));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Table header
|
|
150
|
+
console.log(chalk.bold(` ${"Name".padEnd(20)} ${"Category".padEnd(20)} ${"Price".padEnd(10)} ${"Calls".padEnd(8)} ${"Status".padEnd(10)}`));
|
|
151
|
+
console.log(chalk.dim(" " + "-".repeat(70)));
|
|
152
|
+
for (const skill of skills) {
|
|
153
|
+
const name = (skill.skill_name ?? skill.name ?? "-").slice(0, 19);
|
|
154
|
+
const category = (skill.category ?? "-").slice(0, 19);
|
|
155
|
+
const rawPrice = skill.price ?? skill.price_per_call;
|
|
156
|
+
const price = rawPrice !== undefined ? `$${Number(rawPrice).toFixed(2)}` : "-";
|
|
157
|
+
const calls = String(skill.total_calls ?? skill.call_count ?? "-");
|
|
158
|
+
const status = skill.is_active !== undefined ? (skill.is_active ? "active" : "inactive") : (skill.status ?? "-");
|
|
159
|
+
console.log(` ${chalk.cyan(name.padEnd(20))} ${category.padEnd(20)} ${chalk.green(price.padEnd(10))} ${calls.padEnd(8)} ${status.padEnd(10)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
spinner.fail(chalk.red("Failed to fetch skills"));
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Daemon entry point for the Hub Provider.
|
|
4
|
+
* This file is spawned as a detached child process by `clawmoney hub start`.
|
|
5
|
+
* It runs the provider main loop (WS + Poller + Executor).
|
|
6
|
+
*/
|
|
7
|
+
import { runProvider } from "./provider.js";
|
|
8
|
+
// Parse CLI args passed from the parent
|
|
9
|
+
let cliCommand;
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
if (args[i] === "--cli" && args[i + 1]) {
|
|
13
|
+
cliCommand = args[i + 1];
|
|
14
|
+
i++;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Run the provider (this blocks until shutdown signal)
|
|
18
|
+
runProvider(cliCommand);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
3
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
4
|
+
const seen = new Map();
|
|
5
|
+
let cleanupTimer = null;
|
|
6
|
+
export function isProcessed(orderId) {
|
|
7
|
+
return seen.has(orderId);
|
|
8
|
+
}
|
|
9
|
+
export function markProcessed(orderId) {
|
|
10
|
+
seen.set(orderId, Date.now());
|
|
11
|
+
}
|
|
12
|
+
function cleanup() {
|
|
13
|
+
const cutoff = Date.now() - TTL_MS;
|
|
14
|
+
let removed = 0;
|
|
15
|
+
for (const [id, ts] of seen) {
|
|
16
|
+
if (ts < cutoff) {
|
|
17
|
+
seen.delete(id);
|
|
18
|
+
removed++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (removed > 0) {
|
|
22
|
+
logger.info(`Dedup cleanup: removed ${removed} stale entries, ${seen.size} remaining`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function startDedup() {
|
|
26
|
+
cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL_MS);
|
|
27
|
+
cleanupTimer.unref();
|
|
28
|
+
}
|
|
29
|
+
export function stopDedup() {
|
|
30
|
+
if (cleanupTimer) {
|
|
31
|
+
clearInterval(cleanupTimer);
|
|
32
|
+
cleanupTimer = null;
|
|
33
|
+
}
|
|
34
|
+
seen.clear();
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProviderConfig, ServiceCallEvent, TestCallEvent, DeliverEvent, TestResponseEvent } from "./types.js";
|
|
2
|
+
type SendFn = (event: DeliverEvent | TestResponseEvent) => boolean;
|
|
3
|
+
export declare class Executor {
|
|
4
|
+
private config;
|
|
5
|
+
private send;
|
|
6
|
+
private activeTasks;
|
|
7
|
+
constructor(config: ProviderConfig, send: SendFn);
|
|
8
|
+
get activeCount(): number;
|
|
9
|
+
handleServiceCall(call: ServiceCallEvent): void;
|
|
10
|
+
handleTestCall(call: TestCallEvent): void;
|
|
11
|
+
private executeTask;
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { isProcessed, markProcessed } from "./dedup.js";
|
|
3
|
+
import { replaceLocalPaths } from "./media.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
const TIMEOUT_BUFFER_S = 15;
|
|
6
|
+
function buildPrompt(call, config) {
|
|
7
|
+
const skillConfig = config.provider.skills?.[call.skill];
|
|
8
|
+
if (skillConfig?.prompt_template) {
|
|
9
|
+
return skillConfig.prompt_template
|
|
10
|
+
.replace("{{skill}}", call.skill)
|
|
11
|
+
.replace("{{input}}", JSON.stringify(call.input, null, 2));
|
|
12
|
+
}
|
|
13
|
+
return [
|
|
14
|
+
"You received a paid service request via ClawMoney Hub.",
|
|
15
|
+
`Skill: ${call.skill}`,
|
|
16
|
+
`Input: ${JSON.stringify(call.input, null, 2)}`,
|
|
17
|
+
"",
|
|
18
|
+
"Execute this task and return the result as JSON.",
|
|
19
|
+
"If you generate any files, save them and include their paths in the output.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
function runCli(command, prompt, timeoutMs) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const args = ["-p", prompt, "--output-format", "json"];
|
|
25
|
+
const child = spawn(command, args, {
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
timeout: timeoutMs,
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
let stdout = "";
|
|
31
|
+
let stderr = "";
|
|
32
|
+
child.stdout.on("data", (chunk) => {
|
|
33
|
+
stdout += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on("data", (chunk) => {
|
|
36
|
+
stderr += chunk.toString();
|
|
37
|
+
});
|
|
38
|
+
child.on("close", (code) => {
|
|
39
|
+
resolve({ stdout, stderr, exitCode: code });
|
|
40
|
+
});
|
|
41
|
+
child.on("error", (err) => {
|
|
42
|
+
stderr += err.message;
|
|
43
|
+
resolve({ stdout, stderr, exitCode: null });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function parseJsonOutput(raw) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Ignore
|
|
53
|
+
}
|
|
54
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
55
|
+
if (match) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(match[0]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Ignore
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
export class Executor {
|
|
66
|
+
config;
|
|
67
|
+
send;
|
|
68
|
+
activeTasks = new Set();
|
|
69
|
+
constructor(config, send) {
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.send = send;
|
|
72
|
+
}
|
|
73
|
+
get activeCount() {
|
|
74
|
+
return this.activeTasks.size;
|
|
75
|
+
}
|
|
76
|
+
handleServiceCall(call) {
|
|
77
|
+
if (isProcessed(call.order_id)) {
|
|
78
|
+
logger.info(`Skipping duplicate order: ${call.order_id}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (this.activeTasks.size >= this.config.provider.max_concurrent) {
|
|
82
|
+
logger.warn(`Rejecting order ${call.order_id}: at max concurrency (${this.config.provider.max_concurrent})`);
|
|
83
|
+
this.send({
|
|
84
|
+
event: "deliver",
|
|
85
|
+
order_id: call.order_id,
|
|
86
|
+
error: "Provider is at maximum capacity. Please try again later.",
|
|
87
|
+
});
|
|
88
|
+
markProcessed(call.order_id);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
markProcessed(call.order_id);
|
|
92
|
+
this.activeTasks.add(call.order_id);
|
|
93
|
+
logger.info(`Processing order=${call.order_id} skill="${call.skill}" from=${call.from}`);
|
|
94
|
+
this.executeTask(call).catch((err) => {
|
|
95
|
+
logger.error(`Unhandled error in executeTask for ${call.order_id}:`, err);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
handleTestCall(call) {
|
|
99
|
+
logger.info(`Test call received: order=${call.order_id}`);
|
|
100
|
+
const response = {
|
|
101
|
+
event: "test_response",
|
|
102
|
+
order_id: call.order_id,
|
|
103
|
+
output: {
|
|
104
|
+
echo: call.input,
|
|
105
|
+
provider_status: "ok",
|
|
106
|
+
active_tasks: this.activeTasks.size,
|
|
107
|
+
max_concurrent: this.config.provider.max_concurrent,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
this.send(response);
|
|
111
|
+
}
|
|
112
|
+
async executeTask(call) {
|
|
113
|
+
try {
|
|
114
|
+
const prompt = buildPrompt(call, this.config);
|
|
115
|
+
const timeoutMs = Math.max((call.timeout - TIMEOUT_BUFFER_S) * 1000, 30_000);
|
|
116
|
+
const command = this.config.provider.cli_command;
|
|
117
|
+
logger.info(`Executing: ${command} for skill="${call.skill}" order=${call.order_id} (timeout=${Math.round(timeoutMs / 1000)}s)`);
|
|
118
|
+
const { stdout, stderr, exitCode } = await runCli(command, prompt, timeoutMs);
|
|
119
|
+
if (exitCode !== 0) {
|
|
120
|
+
const errMsg = stderr.trim() || `CLI exited with code ${exitCode}`;
|
|
121
|
+
logger.error(`CLI failed (code=${exitCode}):`, errMsg);
|
|
122
|
+
this.send({
|
|
123
|
+
event: "deliver",
|
|
124
|
+
order_id: call.order_id,
|
|
125
|
+
error: errMsg.slice(0, 2000),
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const parsed = parseJsonOutput(stdout);
|
|
130
|
+
if (!parsed) {
|
|
131
|
+
logger.warn("CLI output was not valid JSON, wrapping as text");
|
|
132
|
+
this.send({
|
|
133
|
+
event: "deliver",
|
|
134
|
+
order_id: call.order_id,
|
|
135
|
+
output: { result: stdout.trim().slice(0, 5000) },
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const output = await replaceLocalPaths(parsed, this.config);
|
|
140
|
+
const sent = this.send({
|
|
141
|
+
event: "deliver",
|
|
142
|
+
order_id: call.order_id,
|
|
143
|
+
output,
|
|
144
|
+
});
|
|
145
|
+
if (sent) {
|
|
146
|
+
logger.info(`Delivered order=${call.order_id} (success)`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
logger.warn(`Failed to send delivery for order=${call.order_id} (WS disconnected)`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
logger.error(`Execution error for order=${call.order_id}:`, err);
|
|
154
|
+
this.send({
|
|
155
|
+
event: "deliver",
|
|
156
|
+
order_id: call.order_id,
|
|
157
|
+
error: err instanceof Error ? err.message : "Unknown execution error",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
this.activeTasks.delete(call.order_id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const LOG_DIR = join(homedir(), ".clawmoney");
|
|
5
|
+
const LOG_FILE = join(LOG_DIR, "provider.log");
|
|
6
|
+
function timestamp() {
|
|
7
|
+
return new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
8
|
+
}
|
|
9
|
+
function ensureDir() {
|
|
10
|
+
try {
|
|
11
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// already exists
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function log(level, ...args) {
|
|
18
|
+
const prefix = `${timestamp()} [${level}]`;
|
|
19
|
+
const message = args
|
|
20
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
21
|
+
.join(" ");
|
|
22
|
+
const line = `${prefix} ${message}\n`;
|
|
23
|
+
// Write to log file
|
|
24
|
+
try {
|
|
25
|
+
ensureDir();
|
|
26
|
+
appendFileSync(LOG_FILE, line, "utf-8");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// best effort
|
|
30
|
+
}
|
|
31
|
+
// Also write to stderr (visible only if not detached)
|
|
32
|
+
switch (level) {
|
|
33
|
+
case "ERROR":
|
|
34
|
+
console.error(prefix, ...args);
|
|
35
|
+
break;
|
|
36
|
+
case "WARN":
|
|
37
|
+
console.warn(prefix, ...args);
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
console.log(prefix, ...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const logger = {
|
|
44
|
+
info: (...args) => log("INFO", ...args),
|
|
45
|
+
warn: (...args) => log("WARN", ...args),
|
|
46
|
+
error: (...args) => log("ERROR", ...args),
|
|
47
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ProviderConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Upload a local file to the Hub media endpoint (R2).
|
|
4
|
+
* Returns the public CDN URL on success, or null on failure.
|
|
5
|
+
*/
|
|
6
|
+
export declare function uploadFile(filePath: string, config: ProviderConfig): Promise<string | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Walk the output object and replace any local file paths with CDN URLs.
|
|
9
|
+
* Mutates the object in-place and returns it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function replaceLocalPaths(output: Record<string, unknown>, config: ProviderConfig): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
/**
|
|
5
|
+
* Upload a local file to the Hub media endpoint (R2).
|
|
6
|
+
* Returns the public CDN URL on success, or null on failure.
|
|
7
|
+
*/
|
|
8
|
+
export async function uploadFile(filePath, config) {
|
|
9
|
+
const url = `${config.provider.api_base_url}/hub/media/upload`;
|
|
10
|
+
try {
|
|
11
|
+
const stat = statSync(filePath);
|
|
12
|
+
if (!stat.isFile()) {
|
|
13
|
+
logger.warn(`uploadFile: not a regular file: ${filePath}`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const fileBuffer = readFileSync(filePath);
|
|
17
|
+
const fileName = basename(filePath);
|
|
18
|
+
const formData = new FormData();
|
|
19
|
+
formData.append("file", new Blob([fileBuffer]), fileName);
|
|
20
|
+
const resp = await fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
24
|
+
},
|
|
25
|
+
body: formData,
|
|
26
|
+
});
|
|
27
|
+
if (!resp.ok) {
|
|
28
|
+
const body = await resp.text();
|
|
29
|
+
logger.error(`uploadFile failed (${resp.status}): ${body}`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const data = (await resp.json());
|
|
33
|
+
if (!data.file_url) {
|
|
34
|
+
logger.error("uploadFile: response missing file_url");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
logger.info(`Uploaded ${fileName} -> ${data.file_url}`);
|
|
38
|
+
return data.file_url;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
logger.error(`uploadFile error for ${filePath}:`, err);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Known output keys that may contain local file paths. */
|
|
46
|
+
const FILE_PATH_KEYS = [
|
|
47
|
+
"image_path",
|
|
48
|
+
"video_path",
|
|
49
|
+
"audio_path",
|
|
50
|
+
"file_path",
|
|
51
|
+
"document_path",
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Walk the output object and replace any local file paths with CDN URLs.
|
|
55
|
+
* Mutates the object in-place and returns it.
|
|
56
|
+
*/
|
|
57
|
+
export async function replaceLocalPaths(output, config) {
|
|
58
|
+
for (const key of FILE_PATH_KEYS) {
|
|
59
|
+
const val = output[key];
|
|
60
|
+
if (typeof val === "string" && val.startsWith("/")) {
|
|
61
|
+
const cdnUrl = await uploadFile(val, config);
|
|
62
|
+
if (cdnUrl) {
|
|
63
|
+
const urlKey = key.replace("_path", "_url");
|
|
64
|
+
output[urlKey] = cdnUrl;
|
|
65
|
+
delete output[key];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ProviderConfig, ServiceCallEvent } from "./types.js";
|
|
2
|
+
export type PollCallback = (event: ServiceCallEvent) => void;
|
|
3
|
+
export declare class Poller {
|
|
4
|
+
private config;
|
|
5
|
+
private onServiceCall;
|
|
6
|
+
private isWsConnected;
|
|
7
|
+
private timer;
|
|
8
|
+
private stopping;
|
|
9
|
+
constructor(config: ProviderConfig, onServiceCall: PollCallback, isWsConnected: () => boolean);
|
|
10
|
+
start(): void;
|
|
11
|
+
stop(): void;
|
|
12
|
+
private scheduleNext;
|
|
13
|
+
private poll;
|
|
14
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
export class Poller {
|
|
3
|
+
config;
|
|
4
|
+
onServiceCall;
|
|
5
|
+
isWsConnected;
|
|
6
|
+
timer = null;
|
|
7
|
+
stopping = false;
|
|
8
|
+
constructor(config, onServiceCall, isWsConnected) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.onServiceCall = onServiceCall;
|
|
11
|
+
this.isWsConnected = isWsConnected;
|
|
12
|
+
}
|
|
13
|
+
start() {
|
|
14
|
+
this.stopping = false;
|
|
15
|
+
this.scheduleNext();
|
|
16
|
+
}
|
|
17
|
+
stop() {
|
|
18
|
+
this.stopping = true;
|
|
19
|
+
if (this.timer) {
|
|
20
|
+
clearTimeout(this.timer);
|
|
21
|
+
this.timer = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
scheduleNext() {
|
|
25
|
+
if (this.stopping)
|
|
26
|
+
return;
|
|
27
|
+
const interval = this.isWsConnected()
|
|
28
|
+
? this.config.provider.polling.connected_interval
|
|
29
|
+
: this.config.provider.polling.disconnected_interval;
|
|
30
|
+
this.timer = setTimeout(async () => {
|
|
31
|
+
this.timer = null;
|
|
32
|
+
await this.poll();
|
|
33
|
+
this.scheduleNext();
|
|
34
|
+
}, interval * 1000);
|
|
35
|
+
this.timer.unref();
|
|
36
|
+
}
|
|
37
|
+
async poll() {
|
|
38
|
+
const url = `${this.config.provider.api_base_url}/hub/tasks/pending`;
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetch(url, {
|
|
41
|
+
method: "GET",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${this.config.api_key}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
if (resp.status !== 404) {
|
|
49
|
+
logger.warn(`Poll failed (${resp.status}): ${await resp.text()}`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const data = (await resp.json());
|
|
54
|
+
const tasks = data.tasks ?? [];
|
|
55
|
+
if (tasks.length > 0) {
|
|
56
|
+
logger.info(`Poll: received ${tasks.length} pending task(s)`);
|
|
57
|
+
}
|
|
58
|
+
for (const task of tasks) {
|
|
59
|
+
this.onServiceCall(task);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
logger.error("Poll error:", err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { WsClient } from "./ws-client.js";
|
|
6
|
+
import { Poller } from "./poller.js";
|
|
7
|
+
import { Executor } from "./executor.js";
|
|
8
|
+
import { startDedup, stopDedup } from "./dedup.js";
|
|
9
|
+
import { logger } from "./logger.js";
|
|
10
|
+
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
11
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
|
|
12
|
+
const PID_FILE = join(CONFIG_DIR, "provider.pid");
|
|
13
|
+
const DEFAULT_PROVIDER = {
|
|
14
|
+
cli_command: "claude",
|
|
15
|
+
max_concurrent: 3,
|
|
16
|
+
ws_url: "wss://api.bnbot.ai/api/v1/ws/agent",
|
|
17
|
+
api_base_url: "https://api.bnbot.ai/api/v1",
|
|
18
|
+
polling: {
|
|
19
|
+
connected_interval: 120,
|
|
20
|
+
disconnected_interval: 15,
|
|
21
|
+
},
|
|
22
|
+
reconnect: {
|
|
23
|
+
initial: 5,
|
|
24
|
+
max: 300,
|
|
25
|
+
multiplier: 2,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
// ── PID helpers ──
|
|
29
|
+
export function readPid() {
|
|
30
|
+
try {
|
|
31
|
+
const content = readFileSync(PID_FILE, "utf-8").trim();
|
|
32
|
+
const pid = parseInt(content, 10);
|
|
33
|
+
return isNaN(pid) ? null : pid;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function isPidAlive(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function writePid() {
|
|
49
|
+
writeFileSync(PID_FILE, String(process.pid), "utf-8");
|
|
50
|
+
}
|
|
51
|
+
export function removePid() {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(PID_FILE);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ── Config loading ──
|
|
60
|
+
function loadProviderConfig(cliCommand) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
64
|
+
raw = YAML.parse(content);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
logger.error(`Failed to read config from ${CONFIG_FILE}:`, err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (!raw.api_key || typeof raw.api_key !== "string") {
|
|
71
|
+
logger.error("api_key is required in config.yaml. Run 'clawmoney setup' first.");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
const userProvider = (raw.provider ?? {});
|
|
75
|
+
const provider = {
|
|
76
|
+
cli_command: cliCommand ?? userProvider.cli_command ?? DEFAULT_PROVIDER.cli_command,
|
|
77
|
+
max_concurrent: userProvider.max_concurrent ?? DEFAULT_PROVIDER.max_concurrent,
|
|
78
|
+
ws_url: userProvider.ws_url ?? DEFAULT_PROVIDER.ws_url,
|
|
79
|
+
api_base_url: userProvider.api_base_url ?? DEFAULT_PROVIDER.api_base_url,
|
|
80
|
+
polling: {
|
|
81
|
+
connected_interval: userProvider.polling?.connected_interval ??
|
|
82
|
+
DEFAULT_PROVIDER.polling.connected_interval,
|
|
83
|
+
disconnected_interval: userProvider.polling?.disconnected_interval ??
|
|
84
|
+
DEFAULT_PROVIDER.polling.disconnected_interval,
|
|
85
|
+
},
|
|
86
|
+
reconnect: {
|
|
87
|
+
initial: userProvider.reconnect?.initial ?? DEFAULT_PROVIDER.reconnect.initial,
|
|
88
|
+
max: userProvider.reconnect?.max ?? DEFAULT_PROVIDER.reconnect.max,
|
|
89
|
+
multiplier: userProvider.reconnect?.multiplier ??
|
|
90
|
+
DEFAULT_PROVIDER.reconnect.multiplier,
|
|
91
|
+
},
|
|
92
|
+
skills: userProvider.skills,
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
api_key: raw.api_key,
|
|
96
|
+
agent_id: raw.agent_id,
|
|
97
|
+
agent_slug: raw.agent_slug,
|
|
98
|
+
provider,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ── Main daemon entry point ──
|
|
102
|
+
export function runProvider(cliCommand) {
|
|
103
|
+
// Check for existing process
|
|
104
|
+
const existingPid = readPid();
|
|
105
|
+
if (existingPid && isPidAlive(existingPid)) {
|
|
106
|
+
logger.error(`Hub Provider is already running (PID ${existingPid}). Use "hub stop" first.`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const config = loadProviderConfig(cliCommand);
|
|
110
|
+
// Initialize dedup
|
|
111
|
+
startDedup();
|
|
112
|
+
// Create WS client
|
|
113
|
+
const wsClient = new WsClient(config, (event) => {
|
|
114
|
+
handleEvent(event);
|
|
115
|
+
});
|
|
116
|
+
// Create executor
|
|
117
|
+
const executor = new Executor(config, (event) => wsClient.send(event));
|
|
118
|
+
// Event router
|
|
119
|
+
function handleEvent(event) {
|
|
120
|
+
switch (event.event) {
|
|
121
|
+
case "connected":
|
|
122
|
+
logger.info(`Connected as "${event.agent_name}" (id=${event.agent_id}, hub_level=${event.hub_level})`);
|
|
123
|
+
break;
|
|
124
|
+
case "service_call":
|
|
125
|
+
executor.handleServiceCall(event);
|
|
126
|
+
break;
|
|
127
|
+
case "test_call":
|
|
128
|
+
executor.handleTestCall(event);
|
|
129
|
+
break;
|
|
130
|
+
case "error":
|
|
131
|
+
logger.error(`Server error: ${event.message}`);
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
logger.warn("Unknown event:", event);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Create poller
|
|
138
|
+
const poller = new Poller(config, (task) => {
|
|
139
|
+
handleEvent(task);
|
|
140
|
+
}, () => wsClient.connected);
|
|
141
|
+
// Graceful shutdown
|
|
142
|
+
let shuttingDown = false;
|
|
143
|
+
function shutdown(signal) {
|
|
144
|
+
if (shuttingDown)
|
|
145
|
+
return;
|
|
146
|
+
shuttingDown = true;
|
|
147
|
+
logger.info(`Received ${signal}. Shutting down...`);
|
|
148
|
+
wsClient.stop();
|
|
149
|
+
poller.stop();
|
|
150
|
+
stopDedup();
|
|
151
|
+
removePid();
|
|
152
|
+
logger.info("Hub Provider stopped.");
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
156
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
157
|
+
// Write PID and start
|
|
158
|
+
writePid();
|
|
159
|
+
wsClient.start();
|
|
160
|
+
poller.start();
|
|
161
|
+
logger.info("Hub Provider running. Listening for service calls...");
|
|
162
|
+
logger.info(`Config: max_concurrent=${config.provider.max_concurrent}, cli=${config.provider.cli_command}`);
|
|
163
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface ServiceCallEvent {
|
|
2
|
+
event: "service_call";
|
|
3
|
+
order_id: string;
|
|
4
|
+
from: string;
|
|
5
|
+
skill: string;
|
|
6
|
+
category: string;
|
|
7
|
+
input: Record<string, unknown>;
|
|
8
|
+
price: number;
|
|
9
|
+
timeout: number;
|
|
10
|
+
payment_method: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TestCallEvent {
|
|
13
|
+
event: "test_call";
|
|
14
|
+
order_id: string;
|
|
15
|
+
input: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface ConnectedEvent {
|
|
18
|
+
event: "connected";
|
|
19
|
+
agent_id: string;
|
|
20
|
+
agent_name: string;
|
|
21
|
+
hub_level: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ErrorEvent {
|
|
24
|
+
event: "error";
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
export type IncomingEvent = ServiceCallEvent | TestCallEvent | ConnectedEvent | ErrorEvent;
|
|
28
|
+
export interface DeliverEvent {
|
|
29
|
+
event: "deliver";
|
|
30
|
+
order_id: string;
|
|
31
|
+
output?: Record<string, unknown>;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface TestResponseEvent {
|
|
35
|
+
event: "test_response";
|
|
36
|
+
order_id: string;
|
|
37
|
+
output: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
export type OutgoingEvent = DeliverEvent | TestResponseEvent;
|
|
40
|
+
export interface ProviderSettings {
|
|
41
|
+
cli_command: string;
|
|
42
|
+
max_concurrent: number;
|
|
43
|
+
ws_url: string;
|
|
44
|
+
api_base_url: string;
|
|
45
|
+
polling: {
|
|
46
|
+
connected_interval: number;
|
|
47
|
+
disconnected_interval: number;
|
|
48
|
+
};
|
|
49
|
+
reconnect: {
|
|
50
|
+
initial: number;
|
|
51
|
+
max: number;
|
|
52
|
+
multiplier: number;
|
|
53
|
+
};
|
|
54
|
+
skills?: Record<string, {
|
|
55
|
+
prompt_template?: string;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
export interface ProviderConfig {
|
|
59
|
+
api_key: string;
|
|
60
|
+
agent_id?: string;
|
|
61
|
+
agent_slug?: string;
|
|
62
|
+
provider: ProviderSettings;
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProviderConfig, IncomingEvent, OutgoingEvent } from "./types.js";
|
|
2
|
+
export type EventCallback = (event: IncomingEvent) => void;
|
|
3
|
+
export declare class WsClient {
|
|
4
|
+
private config;
|
|
5
|
+
private onEvent;
|
|
6
|
+
private ws;
|
|
7
|
+
private heartbeatTimer;
|
|
8
|
+
private reconnectDelay;
|
|
9
|
+
private reconnectTimer;
|
|
10
|
+
private _connected;
|
|
11
|
+
private wsFailLogged;
|
|
12
|
+
private stopping;
|
|
13
|
+
constructor(config: ProviderConfig, onEvent: EventCallback);
|
|
14
|
+
get connected(): boolean;
|
|
15
|
+
start(): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
send(event: OutgoingEvent): boolean;
|
|
18
|
+
private connect;
|
|
19
|
+
private scheduleReconnect;
|
|
20
|
+
private startHeartbeat;
|
|
21
|
+
private stopHeartbeat;
|
|
22
|
+
private clearTimers;
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
4
|
+
export class WsClient {
|
|
5
|
+
config;
|
|
6
|
+
onEvent;
|
|
7
|
+
ws = null;
|
|
8
|
+
heartbeatTimer = null;
|
|
9
|
+
reconnectDelay;
|
|
10
|
+
reconnectTimer = null;
|
|
11
|
+
_connected = false;
|
|
12
|
+
wsFailLogged = false;
|
|
13
|
+
stopping = false;
|
|
14
|
+
constructor(config, onEvent) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.onEvent = onEvent;
|
|
17
|
+
this.reconnectDelay = config.provider.reconnect.initial;
|
|
18
|
+
}
|
|
19
|
+
get connected() {
|
|
20
|
+
return this._connected;
|
|
21
|
+
}
|
|
22
|
+
start() {
|
|
23
|
+
this.stopping = false;
|
|
24
|
+
this.connect();
|
|
25
|
+
}
|
|
26
|
+
stop() {
|
|
27
|
+
this.stopping = true;
|
|
28
|
+
this.clearTimers();
|
|
29
|
+
if (this.ws) {
|
|
30
|
+
this.ws.removeAllListeners();
|
|
31
|
+
this.ws.close();
|
|
32
|
+
this.ws = null;
|
|
33
|
+
}
|
|
34
|
+
this._connected = false;
|
|
35
|
+
}
|
|
36
|
+
send(event) {
|
|
37
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
38
|
+
logger.warn("WS send failed: not connected");
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
this.ws.send(JSON.stringify(event));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.error("WS send error:", err);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── Private ──
|
|
51
|
+
connect() {
|
|
52
|
+
if (this.stopping)
|
|
53
|
+
return;
|
|
54
|
+
const url = `${this.config.provider.ws_url}?api_key=${this.config.api_key}`;
|
|
55
|
+
this.ws = new WebSocket(url);
|
|
56
|
+
this.ws.on("open", () => {
|
|
57
|
+
this._connected = true;
|
|
58
|
+
this.wsFailLogged = false;
|
|
59
|
+
this.reconnectDelay = this.config.provider.reconnect.initial;
|
|
60
|
+
logger.info("WebSocket connected");
|
|
61
|
+
this.startHeartbeat();
|
|
62
|
+
});
|
|
63
|
+
this.ws.on("message", (data) => {
|
|
64
|
+
try {
|
|
65
|
+
const msg = JSON.parse(data.toString());
|
|
66
|
+
this.onEvent(msg);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
logger.error("WS message parse error:", err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
this.ws.on("close", (code, reason) => {
|
|
73
|
+
this._connected = false;
|
|
74
|
+
this.stopHeartbeat();
|
|
75
|
+
if (!this.wsFailLogged) {
|
|
76
|
+
logger.warn(`WebSocket closed (code=${code}, reason=${reason.toString()})`);
|
|
77
|
+
this.wsFailLogged = true;
|
|
78
|
+
}
|
|
79
|
+
this.scheduleReconnect();
|
|
80
|
+
});
|
|
81
|
+
this.ws.on("error", (err) => {
|
|
82
|
+
if (!this.wsFailLogged) {
|
|
83
|
+
logger.error("WebSocket error:", err.message);
|
|
84
|
+
this.wsFailLogged = true;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
scheduleReconnect() {
|
|
89
|
+
if (this.stopping)
|
|
90
|
+
return;
|
|
91
|
+
const delay = this.reconnectDelay;
|
|
92
|
+
logger.info(`Reconnecting in ${delay}s...`);
|
|
93
|
+
this.reconnectTimer = setTimeout(() => {
|
|
94
|
+
this.reconnectTimer = null;
|
|
95
|
+
this.connect();
|
|
96
|
+
}, delay * 1000);
|
|
97
|
+
this.reconnectTimer.unref();
|
|
98
|
+
const { max, multiplier } = this.config.provider.reconnect;
|
|
99
|
+
this.reconnectDelay = Math.min(delay * multiplier, max);
|
|
100
|
+
}
|
|
101
|
+
startHeartbeat() {
|
|
102
|
+
this.stopHeartbeat();
|
|
103
|
+
this.heartbeatTimer = setInterval(() => {
|
|
104
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
105
|
+
this.ws.ping();
|
|
106
|
+
}
|
|
107
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
108
|
+
this.heartbeatTimer.unref();
|
|
109
|
+
}
|
|
110
|
+
stopHeartbeat() {
|
|
111
|
+
if (this.heartbeatTimer) {
|
|
112
|
+
clearInterval(this.heartbeatTimer);
|
|
113
|
+
this.heartbeatTimer = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
clearTimers() {
|
|
117
|
+
this.stopHeartbeat();
|
|
118
|
+
if (this.reconnectTimer) {
|
|
119
|
+
clearTimeout(this.reconnectTimer);
|
|
120
|
+
this.reconnectTimer = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { browseCommand } from './commands/browse.js';
|
|
|
5
5
|
import { promoteSubmitCommand, promoteVerifyCommand } from './commands/promote.js';
|
|
6
6
|
import { walletStatusCommand, walletBalanceCommand, walletAddressCommand, walletSendCommand, } from './commands/wallet.js';
|
|
7
7
|
import { tweetCommand } from './commands/tweet.js';
|
|
8
|
+
import { hubStartCommand, hubStopCommand, hubStatusCommand, hubRegisterCommand, hubSkillsCommand, } from './commands/hub.js';
|
|
8
9
|
const program = new Command();
|
|
9
10
|
program
|
|
10
11
|
.name('clawmoney')
|
|
@@ -137,4 +138,73 @@ program
|
|
|
137
138
|
process.exit(1);
|
|
138
139
|
}
|
|
139
140
|
});
|
|
141
|
+
// hub
|
|
142
|
+
const hub = program
|
|
143
|
+
.command('hub')
|
|
144
|
+
.description('Agent Hub: provide services, register skills');
|
|
145
|
+
hub
|
|
146
|
+
.command('start')
|
|
147
|
+
.description('Start Hub Provider (background process)')
|
|
148
|
+
.option('--cli <command>', 'CLI command for task execution', 'claude')
|
|
149
|
+
.action(async (options) => {
|
|
150
|
+
try {
|
|
151
|
+
await hubStartCommand(options);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
console.error(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
hub
|
|
159
|
+
.command('stop')
|
|
160
|
+
.description('Stop Hub Provider')
|
|
161
|
+
.action(async () => {
|
|
162
|
+
try {
|
|
163
|
+
await hubStopCommand();
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
console.error(err.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
hub
|
|
171
|
+
.command('status')
|
|
172
|
+
.description('Check Hub Provider status')
|
|
173
|
+
.action(async () => {
|
|
174
|
+
try {
|
|
175
|
+
await hubStatusCommand();
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error(err.message);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
hub
|
|
183
|
+
.command('register')
|
|
184
|
+
.description('Register a skill on the Hub')
|
|
185
|
+
.requiredOption('-n, --name <name>', 'Skill name')
|
|
186
|
+
.requiredOption('-c, --category <category>', 'Category (e.g., generation/image)')
|
|
187
|
+
.requiredOption('-d, --description <desc>', 'Description')
|
|
188
|
+
.requiredOption('-p, --price <price>', 'Price per call in USD')
|
|
189
|
+
.action(async (options) => {
|
|
190
|
+
try {
|
|
191
|
+
await hubRegisterCommand(options);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
console.error(err.message);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
hub
|
|
199
|
+
.command('skills')
|
|
200
|
+
.description('List my registered skills')
|
|
201
|
+
.action(async () => {
|
|
202
|
+
try {
|
|
203
|
+
await hubSkillsCommand();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(err.message);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
140
210
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmoney",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "ClawMoney CLI -- Earn rewards with your AI agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
"chalk": "^5.3.0",
|
|
22
22
|
"commander": "^12.0.0",
|
|
23
23
|
"ora": "^8.0.0",
|
|
24
|
+
"ws": "^8.20.0",
|
|
24
25
|
"yaml": "^2.4.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@types/node": "^20.0.0",
|
|
29
|
+
"@types/ws": "^8.18.1",
|
|
28
30
|
"typescript": "^5.4.0"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|