@gholl-studio/pier-connector 0.2.51 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -8
- package/src/cli.ts +29 -0
- package/src/config.ts +29 -0
- package/src/inbound.ts +162 -0
- package/src/index.ts +155 -0
- package/src/job-handler.ts +51 -0
- package/src/robot.ts +300 -0
- package/src/types/pier-sdk.d.ts +20 -0
- package/src/types.ts +32 -0
- package/src/config.js +0 -37
- package/src/index.js +0 -1222
- package/src/job-handler.js +0 -81
package/src/index.js
DELETED
|
@@ -1,1222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pier-connector — OpenClaw plugin entry point.
|
|
3
|
-
*
|
|
4
|
-
* Registers:
|
|
5
|
-
* 1. A messaging channel ("pier") for routing NATS jobs through OpenClaw agent
|
|
6
|
-
* 2. A background service that connects to NATS via WebSocket and subscribes
|
|
7
|
-
* 3. A tool ("pier_publish") for publishing tasks back to Pier
|
|
8
|
-
* 4. A command ("/pier") for checking connection status
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { PierClient, protocol } from '@gholl-studio/pier-sdk';
|
|
12
|
-
const { createRequestPayload, createResultPayload, createErrorPayload } = protocol;
|
|
13
|
-
import { parseJob, safeRespond, truncate } from './job-handler.js';
|
|
14
|
-
import { DEFAULTS } from './config.js';
|
|
15
|
-
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy } from '@nats-io/jetstream';
|
|
16
|
-
import inquirer from 'inquirer';
|
|
17
|
-
import fs from 'fs';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* OpenClaw plugin register function.
|
|
22
|
-
*
|
|
23
|
-
* @param {object} api – OpenClaw plugin API
|
|
24
|
-
*/
|
|
25
|
-
export default function register(api) {
|
|
26
|
-
const logger = api.logger;
|
|
27
|
-
|
|
28
|
-
// ── shared state (Instances) ───────────────────────────────────
|
|
29
|
-
|
|
30
|
-
/** @type {Map<string, any>} */
|
|
31
|
-
const instances = new Map();
|
|
32
|
-
|
|
33
|
-
/** Aggregated stats from all robots since start */
|
|
34
|
-
let totalJobsReceived = 0;
|
|
35
|
-
let totalJobsCompleted = 0;
|
|
36
|
-
let totalJobsFailed = 0;
|
|
37
|
-
|
|
38
|
-
// ── resolve plugin config ──────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
function resolveConfigs() {
|
|
41
|
-
const globalAccounts = api.runtime?.config?.channels?.['pier']?.accounts || {};
|
|
42
|
-
const pluginAccounts = api.config?.channels?.['pier']?.accounts || {};
|
|
43
|
-
const rawAccounts = { ...globalAccounts, ...pluginAccounts };
|
|
44
|
-
|
|
45
|
-
const legacyCfg = api.config?.plugins?.entries?.['pier-connector']?.config || api.config || {};
|
|
46
|
-
|
|
47
|
-
// If no accounts defined at all, fallback to 'default' using legacy/env config
|
|
48
|
-
if (Object.keys(rawAccounts).length === 0) {
|
|
49
|
-
logger.info(`[pier-connector] No accounts found in global or plugin config. Falling back to default.`);
|
|
50
|
-
return [{
|
|
51
|
-
accountId: 'default',
|
|
52
|
-
...mergedCfgFrom(legacyCfg, {})
|
|
53
|
-
}];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const configs = Object.entries(rawAccounts).map(([id, account]) => ({
|
|
57
|
-
accountId: id,
|
|
58
|
-
...mergedCfgFrom(legacyCfg, account)
|
|
59
|
-
}));
|
|
60
|
-
|
|
61
|
-
logger.info(`[pier-connector] Loaded ${configs.length} account(s): ${configs.map(c => c.accountId).join(', ')}`);
|
|
62
|
-
configs.forEach(c => {
|
|
63
|
-
if (c.agentId) logger.info(`[pier-connector] Account '${c.accountId}' has explicit agentId binding: ${c.agentId}`);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
return configs;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function mergedCfgFrom(legacy, account) {
|
|
70
|
-
const merged = { ...legacy, ...account };
|
|
71
|
-
return {
|
|
72
|
-
pierApiUrl: merged.pierApiUrl || DEFAULTS.PIER_API_URL,
|
|
73
|
-
nodeId: merged.nodeId || DEFAULTS.NODE_ID,
|
|
74
|
-
secretKey: merged.secretKey || DEFAULTS.SECRET_KEY,
|
|
75
|
-
privateKey: merged.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
|
|
76
|
-
natsUrl: merged.natsUrl || DEFAULTS.NATS_URL,
|
|
77
|
-
subject: merged.subject || DEFAULTS.SUBJECT,
|
|
78
|
-
publishSubject: merged.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
|
|
79
|
-
queueGroup: merged.queueGroup || DEFAULTS.QUEUE_GROUP,
|
|
80
|
-
agentId: merged.agentId || DEFAULTS.AGENT_ID,
|
|
81
|
-
walletAddress: merged.walletAddress || DEFAULTS.WALLET_ADDRESS,
|
|
82
|
-
capabilities: merged.capabilities || ['translation', 'code-execution', 'reasoning', 'vision'],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* PierRobot class encapsulates the connection, heartbeat, and job handling
|
|
88
|
-
* for a single robot account.
|
|
89
|
-
*/
|
|
90
|
-
class PierRobot {
|
|
91
|
-
constructor(config) {
|
|
92
|
-
this.config = config;
|
|
93
|
-
this.accountId = config.accountId;
|
|
94
|
-
this.client = new PierClient({
|
|
95
|
-
apiUrl: config.pierApiUrl,
|
|
96
|
-
natsUrl: config.natsUrl,
|
|
97
|
-
logger
|
|
98
|
-
});
|
|
99
|
-
this.nc = null;
|
|
100
|
-
this.js = null;
|
|
101
|
-
this.jsm = null;
|
|
102
|
-
this.subscription = null;
|
|
103
|
-
this.heartbeatTimer = null;
|
|
104
|
-
this.connectionStatus = 'disconnected';
|
|
105
|
-
this.jobSubscriptions = new Map();
|
|
106
|
-
this.jobStopTimeouts = new Map();
|
|
107
|
-
this.activeNodeJobs = new Map();
|
|
108
|
-
this.isBusy = false;
|
|
109
|
-
this.stats = { received: 0, completed: 0, failed: 0 };
|
|
110
|
-
this.lastHeartbeatError = null;
|
|
111
|
-
this.connectedAt = null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async init() {
|
|
115
|
-
try {
|
|
116
|
-
this.nc = await this.client.connectNats();
|
|
117
|
-
this.js = jetstream(this.nc);
|
|
118
|
-
this.jsm = await jetstreamManager(this.nc);
|
|
119
|
-
|
|
120
|
-
this.connectedAt = new Date();
|
|
121
|
-
this.connectionStatus = 'connected';
|
|
122
|
-
|
|
123
|
-
// Subscribe to tasks
|
|
124
|
-
await this.subscribeToTasks();
|
|
125
|
-
|
|
126
|
-
// Start heartbeat
|
|
127
|
-
this.startHeartbeat();
|
|
128
|
-
|
|
129
|
-
return true;
|
|
130
|
-
} catch (err) {
|
|
131
|
-
this.connectionStatus = 'error';
|
|
132
|
-
logger.error(`[pier-connector] Account ${this.accountId} failed to connect: ${err.message}`);
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async heartbeat() {
|
|
138
|
-
if (!this.config.nodeId || !this.config.secretKey) return null;
|
|
139
|
-
try {
|
|
140
|
-
const data = await this.client.heartbeat(
|
|
141
|
-
this.config.nodeId,
|
|
142
|
-
this.config.secretKey,
|
|
143
|
-
this.config.capabilities
|
|
144
|
-
);
|
|
145
|
-
this.lastHeartbeatError = null;
|
|
146
|
-
return data.nats_config || null;
|
|
147
|
-
} catch (err) {
|
|
148
|
-
this.lastHeartbeatError = err.message;
|
|
149
|
-
logger.warn(`[pier-connector][${this.accountId}] Heartbeat failed: ${err.message}`);
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async claimJob(jobId) {
|
|
155
|
-
return await this.client.claimJob(jobId, this.config.nodeId, this.config.secretKey);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Routes an incoming message from Pier to the OpenClaw agent.
|
|
160
|
-
* Replaces the non-existent api.runtime.sendIncoming.
|
|
161
|
-
*/
|
|
162
|
-
async receiveIncoming(inbound, jobId) {
|
|
163
|
-
if (!api.runtime?.channel?.reply) {
|
|
164
|
-
logger.error(`[pier-connector][${this.accountId}] SDK Error: api.runtime.channel.reply is not available.`);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
logger.info(`[pier-connector:trace] receiveIncoming triggered for jobId=${jobId}. accountId='${inbound.accountId}', senderId='${inbound.senderId}'`);
|
|
169
|
-
|
|
170
|
-
// 1. Resolve Global Configuration
|
|
171
|
-
// In OpenClaw V2, we need the FULL config for routing to work, but api.runtime.config is often scoped to the plugin.
|
|
172
|
-
// We search for the root config and fall back to whatever is available.
|
|
173
|
-
const rootConfig = api.rootConfig || api.runtime?.globalConfig || api.runtime?.config || {};
|
|
174
|
-
|
|
175
|
-
// Diagnostic logging (only if we suspect it's still failing)
|
|
176
|
-
const bindingsCount = Array.isArray(rootConfig.bindings) ? rootConfig.bindings.length : 0;
|
|
177
|
-
const agentsCount = rootConfig.agents?.list ? (Array.isArray(rootConfig.agents.list) ? rootConfig.agents.list.length : Object.keys(rootConfig.agents.list).length) : 0;
|
|
178
|
-
|
|
179
|
-
logger.info(`[pier-connector:trace] Diagnostic: rootConfig source=${api.rootConfig ? 'api.rootConfig' : (api.runtime?.globalConfig ? 'globalConfig' : 'runtime.config')}, bindings=${bindingsCount}, agents=${agentsCount}`);
|
|
180
|
-
|
|
181
|
-
// 2. Resolve Agent Route via SDK
|
|
182
|
-
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
183
|
-
cfg: rootConfig,
|
|
184
|
-
channel: 'pier',
|
|
185
|
-
account: inbound.accountId,
|
|
186
|
-
peer: { kind: 'direct', id: jobId }
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
logger.info(`[pier-connector:trace] resolveAgentRoute returned: route.agentId='${route?.agentId}', route.accountId='${route?.accountId}'`);
|
|
190
|
-
|
|
191
|
-
// 3. Robust Routing Decision Tree
|
|
192
|
-
let finalAgentId = null;
|
|
193
|
-
let routingSource = 'unresolved';
|
|
194
|
-
|
|
195
|
-
// A. Check explicit account-level binding in plugin local config (this.config)
|
|
196
|
-
if (this.config.agentId && this.config.agentId !== 'main' && this.config.agentId !== 'default') {
|
|
197
|
-
finalAgentId = this.config.agentId;
|
|
198
|
-
routingSource = 'plugin-local-config';
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// B. Check Global Bindings (Manual Scan of rootConfig.bindings)
|
|
202
|
-
if (!finalAgentId) {
|
|
203
|
-
const bindings = Array.isArray(rootConfig.bindings) ? rootConfig.bindings : [];
|
|
204
|
-
const binding = bindings.find(b =>
|
|
205
|
-
b.match?.channel === 'pier' &&
|
|
206
|
-
(b.match?.accountId === inbound.accountId || b.match?.account === inbound.accountId)
|
|
207
|
-
);
|
|
208
|
-
if (binding?.agentId && binding.agentId !== 'main') {
|
|
209
|
-
finalAgentId = binding.agentId;
|
|
210
|
-
routingSource = 'manual-global-bindings-match';
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// C. SDK Routing Result (if it actually found something non-default)
|
|
215
|
-
if (!finalAgentId && route.agentId && route.agentId !== 'main' && route.agentId !== 'default') {
|
|
216
|
-
finalAgentId = route.agentId;
|
|
217
|
-
routingSource = 'sdk-global-bindings';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// D. Name-Matching Fallback (CRITICAL: account name == agent ID)
|
|
221
|
-
// If we see account 'sunwukong', and we have an agent 'sunwukong', route there!
|
|
222
|
-
if (!finalAgentId && inbound.accountId && inbound.accountId !== 'default' && inbound.accountId !== 'main') {
|
|
223
|
-
// Check if such an agent exists in rootConfig
|
|
224
|
-
const agents = rootConfig.agents?.list || {};
|
|
225
|
-
const agentExists = Array.isArray(agents) ? agents.some(a => a.id === inbound.accountId) : !!agents[inbound.accountId];
|
|
226
|
-
|
|
227
|
-
if (agentExists) {
|
|
228
|
-
finalAgentId = inbound.accountId;
|
|
229
|
-
routingSource = 'account-agent-name-match';
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// E. Ultimate Fallback
|
|
234
|
-
if (!finalAgentId) {
|
|
235
|
-
finalAgentId = route.agentId || 'main'; // Use SDK result or hard default 'main'
|
|
236
|
-
routingSource = route.agentId ? 'sdk-default-fallback' : 'hardcoded-main-fallback';
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
logger.info(`[pier-connector] Routing account '${inbound.accountId}' -> agent '${finalAgentId}' (Source: ${routingSource})`);
|
|
240
|
-
|
|
241
|
-
const dynamicSessionKey = `pier-job-${jobId}`;
|
|
242
|
-
const metadata = this.activeNodeJobs.get(jobId);
|
|
243
|
-
let injectedPrompt = "";
|
|
244
|
-
|
|
245
|
-
if (metadata && metadata.isTargeted === false) {
|
|
246
|
-
injectedPrompt = [
|
|
247
|
-
"=========================================",
|
|
248
|
-
"CRITICAL DIRECTIVE - OPEN BIDDING MARKETPLACE",
|
|
249
|
-
"=========================================",
|
|
250
|
-
"You are an autonomous AI worker on Pier. This job currently has NO assigned worker.",
|
|
251
|
-
"1. DO NOT directly solve the user's task or output the final answer right now.",
|
|
252
|
-
"2. You MUST USE the \`pier_bid_task\` tool to send a pitch/bid to the employer explaining why you are best equipped to solve this.",
|
|
253
|
-
"3. Stop executing after submitting the bid.",
|
|
254
|
-
"4. You MUST use the Job ID provided below when calling the tool.",
|
|
255
|
-
`Job ID: ${jobId}`,
|
|
256
|
-
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
257
|
-
].join('\n');
|
|
258
|
-
} else {
|
|
259
|
-
injectedPrompt = [
|
|
260
|
-
"=========================================",
|
|
261
|
-
"CRITICAL DIRECTIVE - PIER TRADING NETWORK",
|
|
262
|
-
"=========================================",
|
|
263
|
-
"You are an autonomous AI worker on Pier.",
|
|
264
|
-
"1. You MUST USE the \`pier_finish_task\` tool to submit the final result when your work is complete.",
|
|
265
|
-
"2. If you need to negotiate or clarify requirements with the employer, use \`pier_chat\`.",
|
|
266
|
-
"3. Do not just wait in silence. Always officially finish the task if the employer's goal is met.",
|
|
267
|
-
inbound.systemPrompt ? `\nEmployer Guidelines:\n${inbound.systemPrompt}` : ""
|
|
268
|
-
].join('\n');
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// MsgContext uses PascalCase for keys in OpenClaw V2 SDK
|
|
272
|
-
const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
|
|
273
|
-
agentId: finalAgentId, // Keep lowercase here as it's the target system ID
|
|
274
|
-
Body: inbound.text,
|
|
275
|
-
BodyForAgent: inbound.text,
|
|
276
|
-
RawBody: inbound.text,
|
|
277
|
-
From: inbound.senderId,
|
|
278
|
-
To: `pier:${jobId}`,
|
|
279
|
-
SessionKey: dynamicSessionKey,
|
|
280
|
-
AccountId: inbound.accountId,
|
|
281
|
-
ChatType: 'direct',
|
|
282
|
-
SenderId: inbound.senderId,
|
|
283
|
-
Provider: 'pier',
|
|
284
|
-
Surface: 'pier',
|
|
285
|
-
OriginatingChannel: 'pier',
|
|
286
|
-
OriginatingTo: `pier:${jobId}`,
|
|
287
|
-
WasMentioned: true,
|
|
288
|
-
CommandAuthorized: true,
|
|
289
|
-
SystemPrompt: injectedPrompt,
|
|
290
|
-
MessageId: inbound.messageId || jobId,
|
|
291
|
-
Metadata: {
|
|
292
|
-
...metadata,
|
|
293
|
-
accountId: this.accountId,
|
|
294
|
-
pierJobId: jobId,
|
|
295
|
-
routingSource: routingSource
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
const { dispatcher, markDispatchIdle } = api.runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
300
|
-
cfg: api.runtime.config,
|
|
301
|
-
agentId: finalAgentId,
|
|
302
|
-
deliver: async (payload) => {
|
|
303
|
-
const currentMeta = this.activeNodeJobs.get(jobId);
|
|
304
|
-
await pierChannel.outbound.sendText({
|
|
305
|
-
text: payload.text,
|
|
306
|
-
to: `pier:${jobId}`,
|
|
307
|
-
metadata: {
|
|
308
|
-
...currentMeta,
|
|
309
|
-
accountId: this.accountId,
|
|
310
|
-
pierJobId: jobId
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
if (api.runtime.channel.session?.recordSessionMetaFromInbound) {
|
|
317
|
-
try {
|
|
318
|
-
const storePath = api.runtime.channel.session.resolveStorePath(api.config, dynamicSessionKey);
|
|
319
|
-
await api.runtime.channel.session.recordSessionMetaFromInbound({
|
|
320
|
-
storePath, sessionKey: dynamicSessionKey, ctx: ctxPayload
|
|
321
|
-
});
|
|
322
|
-
} catch (err) {}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
await api.runtime.channel.reply.dispatchReplyFromConfig({
|
|
327
|
-
ctx: ctxPayload, cfg: api.runtime.config, dispatcher
|
|
328
|
-
});
|
|
329
|
-
} finally {
|
|
330
|
-
markDispatchIdle();
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
async subscribeToJobMessages(jobId) {
|
|
334
|
-
if (!this.js) return;
|
|
335
|
-
|
|
336
|
-
const chatSubject = `chat.${jobId}`;
|
|
337
|
-
const streamName = 'PIER_JOBS';
|
|
338
|
-
const nodeSlug = this.config.nodeId.replace(/[^a-zA-Z0-9]/g, '_');
|
|
339
|
-
const jobSlug = jobId.replace(/[^a-zA-Z0-9]/g, '_');
|
|
340
|
-
const durableName = `pier_chat_${nodeSlug}_${jobSlug}`;
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
let consumer;
|
|
344
|
-
try {
|
|
345
|
-
consumer = await this.js.consumers.get(streamName, durableName);
|
|
346
|
-
} catch {
|
|
347
|
-
await this.jsm.consumers.add(streamName, {
|
|
348
|
-
durable_name: durableName,
|
|
349
|
-
filter_subject: chatSubject,
|
|
350
|
-
deliver_policy: DeliverPolicy.New,
|
|
351
|
-
ack_policy: AckPolicy.Explicit,
|
|
352
|
-
ack_wait: 1000 * 60 * 60,
|
|
353
|
-
});
|
|
354
|
-
consumer = await this.js.consumers.get(streamName, durableName);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const iter = await consumer.consume();
|
|
358
|
-
this.jobSubscriptions.set(jobId, iter);
|
|
359
|
-
|
|
360
|
-
(async () => {
|
|
361
|
-
logger.info(`[pier-connector][${this.accountId}] \u{1F442} Monitoring ${chatSubject}`);
|
|
362
|
-
const processedMessages = new Set();
|
|
363
|
-
for await (const msg of iter) {
|
|
364
|
-
try {
|
|
365
|
-
const rawMsg = new TextDecoder().decode(msg.data);
|
|
366
|
-
let msgPayload = JSON.parse(rawMsg);
|
|
367
|
-
|
|
368
|
-
if (msgPayload.id && processedMessages.has(msgPayload.id)) { msg.ack(); continue; }
|
|
369
|
-
if (msgPayload.sender_id === this.config.nodeId) { msg.ack(); continue; }
|
|
370
|
-
if (msgPayload.type === 'receipt') { msg.ack(); continue; }
|
|
371
|
-
// Skip system_action messages — those are for the backend state machine
|
|
372
|
-
if (msgPayload.type === 'system_action' || msgPayload.msg_type === 'system_action') { msg.ack(); continue; }
|
|
373
|
-
|
|
374
|
-
// Send receipt
|
|
375
|
-
try {
|
|
376
|
-
await this.js.publish(chatSubject, new TextEncoder().encode(JSON.stringify({
|
|
377
|
-
type: 'receipt', msg_id: msgPayload.id, reader_id: this.config.nodeId
|
|
378
|
-
})));
|
|
379
|
-
} catch (err) {}
|
|
380
|
-
|
|
381
|
-
const content = msgPayload.content;
|
|
382
|
-
if (!content) { msg.ack(); continue; }
|
|
383
|
-
|
|
384
|
-
logger.info(`[pier-connector][${this.accountId}] \u{1F4AC} Chat: [${jobId}] ${msgPayload.sender_id}: "${truncate(content, 40)}"`);
|
|
385
|
-
if (msgPayload.id) processedMessages.add(msgPayload.id);
|
|
386
|
-
|
|
387
|
-
// Only the assigned robot should process chat messages
|
|
388
|
-
const jobMeta = this.activeNodeJobs.get(jobId);
|
|
389
|
-
if (!jobMeta || !jobMeta.isTargeted) {
|
|
390
|
-
msg.ack();
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
msg.ack();
|
|
395
|
-
|
|
396
|
-
logger.info(`[pier-connector:trace] NATS Chat Message received on PierRobot instance accountId='${this.accountId}'. Passing to receiveIncoming...`);
|
|
397
|
-
await this.receiveIncoming({
|
|
398
|
-
accountId: this.accountId,
|
|
399
|
-
senderId: `pier:${msgPayload.sender_id}`,
|
|
400
|
-
text: content,
|
|
401
|
-
}, jobId);
|
|
402
|
-
} catch (err) { logger.error(`[pier-connector] Chat err: ${err.message}`); }
|
|
403
|
-
}
|
|
404
|
-
})();
|
|
405
|
-
} catch (err) {
|
|
406
|
-
logger.error(`[pier-connector][${this.accountId}] Failed to setup chat consumer: ${err.message}`);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async handleMessage(msg) {
|
|
411
|
-
const rawData = new TextDecoder().decode(msg.data);
|
|
412
|
-
let payload;
|
|
413
|
-
try {
|
|
414
|
-
payload = JSON.parse(rawData);
|
|
415
|
-
} catch (e) {
|
|
416
|
-
msg.ack();
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (payload.type === 'wakeup') {
|
|
421
|
-
const { jobId } = payload;
|
|
422
|
-
if (jobId) {
|
|
423
|
-
logger.info(`[pier-connector][${this.accountId}] ⏰ Received wakeup signal for job ${jobId}`);
|
|
424
|
-
if (!this.jobSubscriptions.has(jobId)) {
|
|
425
|
-
this.activeNodeJobs.set(jobId, { pierJobId: jobId, isTargeted: true });
|
|
426
|
-
await this.subscribeToJobMessages(jobId);
|
|
427
|
-
}
|
|
428
|
-
msg.ack();
|
|
429
|
-
} else {
|
|
430
|
-
msg.ack();
|
|
431
|
-
}
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (this.isBusy) {
|
|
436
|
-
logger.debug(`[pier-connector][${this.accountId}] 🚫 Busy. Ignoring payload...`);
|
|
437
|
-
msg.nak();
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (payload.assigned_node_id && payload.assigned_node_id !== this.config.nodeId) {
|
|
442
|
-
msg.nak();
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const jobIdToClaim = payload.id;
|
|
447
|
-
const isTargeted = !!(payload.assigned_node_id || payload.targetNodeId || payload.TargetNodeID);
|
|
448
|
-
|
|
449
|
-
if (jobIdToClaim && isTargeted) {
|
|
450
|
-
const claimResult = await this.claimJob(jobIdToClaim);
|
|
451
|
-
if (!claimResult.ok) {
|
|
452
|
-
if (claimResult.alreadyClaimed) msg.ack();
|
|
453
|
-
else msg.nak();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
this.isBusy = true;
|
|
459
|
-
msg.ack();
|
|
460
|
-
|
|
461
|
-
this.stats.received++;
|
|
462
|
-
totalJobsReceived++;
|
|
463
|
-
|
|
464
|
-
const parsed = parseJob(msg, logger);
|
|
465
|
-
if (!parsed.ok) {
|
|
466
|
-
this.stats.failed++;
|
|
467
|
-
totalJobsFailed++;
|
|
468
|
-
this.isBusy = false;
|
|
469
|
-
logger.error(`[pier-connector][${this.accountId}] Job parse error: ${parsed.error}`);
|
|
470
|
-
safeRespond(msg, createErrorPayload({
|
|
471
|
-
id: jobIdToClaim || 'unknown',
|
|
472
|
-
errorCode: 'PARSE_ERROR',
|
|
473
|
-
errorMessage: parsed.error,
|
|
474
|
-
workerId: this.config.nodeId,
|
|
475
|
-
walletAddress: this.config.walletAddress,
|
|
476
|
-
}));
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const { job } = parsed;
|
|
481
|
-
const senderCore = job.meta?.sender ?? 'anonymous';
|
|
482
|
-
|
|
483
|
-
this.activeNodeJobs.set(job.id, {
|
|
484
|
-
pierJobId: job.id,
|
|
485
|
-
pierNatsMsg: msg,
|
|
486
|
-
pierStartTime: performance.now(),
|
|
487
|
-
pierMeta: job.meta,
|
|
488
|
-
isRealtimeMsg: false,
|
|
489
|
-
isTargeted: isTargeted
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
let finalText = job.task;
|
|
493
|
-
if (!isTargeted) {
|
|
494
|
-
finalText = `【PIER MARKETPLACE OPEN JOB】\nJob ID: ${job.id}\nTask: ${job.task}\n\n=== CRITICAL ===\nYou MUST use \`pier_bid_task\` to bid. Do not solve directly.`;
|
|
495
|
-
} else {
|
|
496
|
-
// Only subscribe to chat for targeted/assigned jobs
|
|
497
|
-
await this.subscribeToJobMessages(job.id);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
logger.info(`[pier-connector:trace] NATS Marketplace Job received on PierRobot instance accountId='${this.accountId}'. Passing to receiveIncoming...`);
|
|
501
|
-
await this.receiveIncoming({
|
|
502
|
-
accountId: this.accountId,
|
|
503
|
-
senderId: `pier:${senderCore}`,
|
|
504
|
-
text: finalText,
|
|
505
|
-
}, job.id);
|
|
506
|
-
|
|
507
|
-
this.isBusy = false;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
async setupMarketplaceConsumer(streamName, subjectName, durableName) {
|
|
511
|
-
try {
|
|
512
|
-
let consumer;
|
|
513
|
-
try {
|
|
514
|
-
consumer = await this.js.consumers.get(streamName, durableName);
|
|
515
|
-
} catch {
|
|
516
|
-
await this.jsm.consumers.add(streamName, {
|
|
517
|
-
durable_name: durableName,
|
|
518
|
-
filter_subject: subjectName,
|
|
519
|
-
deliver_policy: DeliverPolicy.New,
|
|
520
|
-
ack_policy: AckPolicy.Explicit,
|
|
521
|
-
});
|
|
522
|
-
consumer = await this.js.consumers.get(streamName, durableName);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const iter = await consumer.consume();
|
|
526
|
-
this.subscription = iter;
|
|
527
|
-
|
|
528
|
-
(async () => {
|
|
529
|
-
for await (const msg of iter) {
|
|
530
|
-
await this.handleMessage(msg);
|
|
531
|
-
}
|
|
532
|
-
})().catch(err => {
|
|
533
|
-
logger.error(`[pier-connector][${this.accountId}] Consumer error: ${err.message}`);
|
|
534
|
-
});
|
|
535
|
-
} catch (err) {
|
|
536
|
-
logger.error(`[pier-connector][${this.accountId}] Setup failed: ${err.message}`);
|
|
537
|
-
throw err;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async autoRegister() {
|
|
542
|
-
const hostName = `${api?.getRuntimeInfo?.()?.hostname ?? 'Auto'}-${this.accountId}`;
|
|
543
|
-
const { nodeId, secretKey } = await this.client.autoRegister(this.config.privateKey, hostName);
|
|
544
|
-
this.config.nodeId = nodeId;
|
|
545
|
-
this.config.secretKey = secretKey;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
async start() {
|
|
549
|
-
this.connectionStatus = 'connecting';
|
|
550
|
-
try {
|
|
551
|
-
if (!this.config.nodeId && this.config.privateKey) await this.autoRegister();
|
|
552
|
-
const natsConfig = await this.heartbeat();
|
|
553
|
-
if (!natsConfig) throw new Error('No NATS config');
|
|
554
|
-
if (natsConfig.url) {
|
|
555
|
-
// Update SDK NATS client with latest URL from heartbeat
|
|
556
|
-
this.client.nats.url = natsConfig.url;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
this.nc = await this.client.connectNats();
|
|
560
|
-
this.js = jetstream(this.nc);
|
|
561
|
-
this.jsm = await jetstreamManager(this.nc);
|
|
562
|
-
this.connectionStatus = 'connected';
|
|
563
|
-
this.connectedAt = new Date();
|
|
564
|
-
|
|
565
|
-
const streamName = 'PIER_JOBS';
|
|
566
|
-
const durableNameMarket = `pier_market_${this.config.nodeId.replace(/-/g, '_')}`;
|
|
567
|
-
const durableNameDirect = `pier_node_${this.config.nodeId.replace(/-/g, '_')}`;
|
|
568
|
-
|
|
569
|
-
await this.setupMarketplaceConsumer(streamName, this.config.subject, durableNameMarket);
|
|
570
|
-
await this.setupMarketplaceConsumer(streamName, `jobs.node.${this.config.nodeId}`, durableNameDirect);
|
|
571
|
-
|
|
572
|
-
this.heartbeatTimer = setInterval(() => this.heartbeat(), 60000);
|
|
573
|
-
} catch (err) {
|
|
574
|
-
this.connectionStatus = 'error';
|
|
575
|
-
logger.error(`[pier-connector][${this.accountId}] Start failed: ${err.message}`);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
async stop() {
|
|
580
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
581
|
-
if (this.nc) await this.client.drainNats();
|
|
582
|
-
this.connectionStatus = 'disconnected';
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// ── 1. Register messaging channel ──────────────────────────────────
|
|
587
|
-
|
|
588
|
-
const pierChannel = {
|
|
589
|
-
id: 'pier',
|
|
590
|
-
|
|
591
|
-
meta: {
|
|
592
|
-
id: 'pier',
|
|
593
|
-
label: 'Pier',
|
|
594
|
-
selectionLabel: 'Pier (NATS Job Marketplace)',
|
|
595
|
-
docsPath: '/plugins/pier-connector',
|
|
596
|
-
blurb: 'Accept and publish tasks on the Pier job marketplace via NATS.',
|
|
597
|
-
aliases: ['pier-connector'],
|
|
598
|
-
},
|
|
599
|
-
|
|
600
|
-
id: 'pier',
|
|
601
|
-
|
|
602
|
-
capabilities: {
|
|
603
|
-
chatTypes: ['direct'],
|
|
604
|
-
},
|
|
605
|
-
|
|
606
|
-
config: {
|
|
607
|
-
listAccountIds: (cfg) => Object.keys(cfg.channels?.pier?.accounts ?? {}),
|
|
608
|
-
resolveAccount: (cfg, accountId) => cfg.channels?.pier?.accounts?.[accountId ?? 'default'] ?? {
|
|
609
|
-
accountId: accountId ?? 'default',
|
|
610
|
-
enabled: true,
|
|
611
|
-
},
|
|
612
|
-
isConfigured: (account) => Boolean(account.nodeId && account.secretKey) || Boolean(account.privateKey),
|
|
613
|
-
},
|
|
614
|
-
|
|
615
|
-
setup: {
|
|
616
|
-
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
617
|
-
const draft = structuredClone(cfg);
|
|
618
|
-
draft.channels = draft.channels || {};
|
|
619
|
-
draft.channels.pier = draft.channels.pier || {};
|
|
620
|
-
draft.channels.pier.accounts = draft.channels.pier.accounts || {};
|
|
621
|
-
|
|
622
|
-
const acct = draft.channels.pier.accounts[accountId] || { enabled: true };
|
|
623
|
-
|
|
624
|
-
if (input.token) {
|
|
625
|
-
const parts = input.token.split(':');
|
|
626
|
-
if (parts.length >= 2) {
|
|
627
|
-
acct.nodeId = parts[0];
|
|
628
|
-
acct.secretKey = parts.slice(1).join(':');
|
|
629
|
-
} else {
|
|
630
|
-
// fallback if user just pasted one string
|
|
631
|
-
acct.nodeId = parts[0];
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
draft.channels.pier.accounts[accountId] = acct;
|
|
636
|
-
return draft;
|
|
637
|
-
}
|
|
638
|
-
},
|
|
639
|
-
|
|
640
|
-
// Note: login is now moved to top-level of api.registerChannel
|
|
641
|
-
|
|
642
|
-
outbound: {
|
|
643
|
-
deliveryMode: 'direct',
|
|
644
|
-
|
|
645
|
-
sendText: async (ctx) => {
|
|
646
|
-
const text = ctx.text;
|
|
647
|
-
let metadata = ctx.metadata;
|
|
648
|
-
const accountId = metadata?.accountId || 'default';
|
|
649
|
-
const robot = instances.get(accountId);
|
|
650
|
-
|
|
651
|
-
logger.info(`[pier-connector][${accountId}] 📤 Agent sending reply: "${truncate(text, 40)}" (To: ${ctx.to})`);
|
|
652
|
-
|
|
653
|
-
if (!robot) {
|
|
654
|
-
logger.error(`[pier-connector] ✖ No robot instance found for account ${accountId}`);
|
|
655
|
-
return { ok: false, error: 'Robot instance not found' };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (!metadata && ctx.to) {
|
|
659
|
-
const toId = ctx.to.replace(/^pier:/, '');
|
|
660
|
-
metadata = robot.activeNodeJobs.get(toId);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const jobId = metadata?.pierJobId;
|
|
664
|
-
|
|
665
|
-
if (jobId && robot.js) {
|
|
666
|
-
try {
|
|
667
|
-
const replySubject = `chat.${jobId}`;
|
|
668
|
-
|
|
669
|
-
// Only publish replies for jobs assigned to this robot
|
|
670
|
-
if (!metadata?.isTargeted) {
|
|
671
|
-
logger.debug(`[pier-connector][${accountId}] 🫨 Suppressing reply for non-assigned job ${jobId}`);
|
|
672
|
-
} else {
|
|
673
|
-
const chatPayload = {
|
|
674
|
-
id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).substring(2) + Date.now().toString(36)),
|
|
675
|
-
job_id: jobId,
|
|
676
|
-
sender_id: robot.config.nodeId || 'anonymous',
|
|
677
|
-
sender_name: accountId,
|
|
678
|
-
sender_type: 'node',
|
|
679
|
-
content: text,
|
|
680
|
-
created_at: new Date().toISOString(),
|
|
681
|
-
auth_token: robot.config.secretKey
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
await robot.js.publish(replySubject, new TextEncoder().encode(JSON.stringify(chatPayload)));
|
|
685
|
-
logger.info(`[pier-connector][${accountId}] 💬 Agent reply published to NATS for job ${jobId} (Subject: ${replySubject})`);
|
|
686
|
-
}
|
|
687
|
-
} catch (err) {
|
|
688
|
-
logger.error(`[pier-connector][${accountId}] Failed to publish reply: ${err.message}`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Handle listener timeout
|
|
692
|
-
if (robot.jobSubscriptions.has(jobId)) {
|
|
693
|
-
if (robot.jobStopTimeouts.has(jobId)) clearTimeout(robot.jobStopTimeouts.get(jobId));
|
|
694
|
-
|
|
695
|
-
const timeout = setTimeout(() => {
|
|
696
|
-
const sub = robot.jobSubscriptions.get(jobId);
|
|
697
|
-
if (sub) {
|
|
698
|
-
logger.info(`[pier-connector][${accountId}] \u{1F6D1} Stopping listener for job ${jobId} (inactivity)`);
|
|
699
|
-
sub.stop();
|
|
700
|
-
robot.jobSubscriptions.delete(jobId);
|
|
701
|
-
robot.jobStopTimeouts.delete(jobId);
|
|
702
|
-
}
|
|
703
|
-
}, 3600000); // 1 hour
|
|
704
|
-
robot.jobStopTimeouts.set(jobId, timeout);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
return { ok: true };
|
|
709
|
-
},
|
|
710
|
-
},
|
|
711
|
-
|
|
712
|
-
status: {
|
|
713
|
-
defaultRuntime: {
|
|
714
|
-
accountId: 'default',
|
|
715
|
-
running: false,
|
|
716
|
-
connected: false,
|
|
717
|
-
},
|
|
718
|
-
buildAccountSnapshot: ({ account }) => {
|
|
719
|
-
const robot = instances.get(account.accountId);
|
|
720
|
-
return {
|
|
721
|
-
accountId: account.accountId,
|
|
722
|
-
name: account.name || `Robot ${account.accountId}`,
|
|
723
|
-
enabled: account.enabled,
|
|
724
|
-
configured: Boolean(account.nodeId && account.secretKey) || Boolean(account.privateKey),
|
|
725
|
-
running: robot ? (robot.connectionStatus === 'connected' || robot.connectionStatus === 'connecting') : false,
|
|
726
|
-
connected: robot ? (robot.connectionStatus === 'connected' && !robot.lastHeartbeatError) : false,
|
|
727
|
-
lastError: robot?.lastHeartbeatError || null,
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
},
|
|
731
|
-
};
|
|
732
|
-
|
|
733
|
-
api.registerChannel({
|
|
734
|
-
id: 'pier',
|
|
735
|
-
plugin: pierChannel,
|
|
736
|
-
login: {
|
|
737
|
-
handler: async () => {
|
|
738
|
-
console.log('\n🚢 \x1b[1m\x1b[36mPier Channel Login\x1b[0m');
|
|
739
|
-
|
|
740
|
-
const answers = await inquirer.prompt([
|
|
741
|
-
{
|
|
742
|
-
type: 'input',
|
|
743
|
-
name: 'accountId',
|
|
744
|
-
message: 'Account Name (e.g., sunwukong, jolin):',
|
|
745
|
-
default: 'default',
|
|
746
|
-
validate: (input) => input.trim().length > 0 || 'Account name is required'
|
|
747
|
-
},
|
|
748
|
-
{
|
|
749
|
-
type: 'input',
|
|
750
|
-
name: 'pierApiUrl',
|
|
751
|
-
message: 'Pier API URL:',
|
|
752
|
-
default: DEFAULTS.PIER_API_URL
|
|
753
|
-
},
|
|
754
|
-
{
|
|
755
|
-
type: 'input',
|
|
756
|
-
name: 'nodeId',
|
|
757
|
-
message: 'Bot Node ID (UUID):',
|
|
758
|
-
validate: (input) => input.trim().length > 0 || 'Node ID is required'
|
|
759
|
-
},
|
|
760
|
-
{
|
|
761
|
-
type: 'password',
|
|
762
|
-
name: 'secretKey',
|
|
763
|
-
message: 'Bot Secret Key:',
|
|
764
|
-
validate: (input) => input.trim().length > 0 || 'Secret Key is required'
|
|
765
|
-
},
|
|
766
|
-
]);
|
|
767
|
-
|
|
768
|
-
console.log('\n\x1b[36mVerifying connection...\x1b[0m');
|
|
769
|
-
try {
|
|
770
|
-
const tempClient = new PierClient({ apiUrl: answers.pierApiUrl });
|
|
771
|
-
await tempClient.heartbeat(answers.nodeId, answers.secretKey);
|
|
772
|
-
|
|
773
|
-
console.log('\x1b[32m✔ Verified successfully!\x1b[0m');
|
|
774
|
-
|
|
775
|
-
return {
|
|
776
|
-
accountId: answers.accountId,
|
|
777
|
-
config: {
|
|
778
|
-
enabled: true,
|
|
779
|
-
pierApiUrl: answers.pierApiUrl,
|
|
780
|
-
nodeId: answers.nodeId,
|
|
781
|
-
secretKey: answers.secretKey
|
|
782
|
-
}
|
|
783
|
-
};
|
|
784
|
-
} catch (err) {
|
|
785
|
-
throw new Error(`Failed to verify Pier credentials: ${err.message}`);
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
// ── 2. Register background service ─────────────────────────────────
|
|
792
|
-
|
|
793
|
-
api.registerService({
|
|
794
|
-
id: 'pier-connector',
|
|
795
|
-
|
|
796
|
-
start: async () => {
|
|
797
|
-
const configs = resolveConfigs();
|
|
798
|
-
logger.info(`[pier-connector] 🚀 Starting background service for ${configs.length} robots …`);
|
|
799
|
-
|
|
800
|
-
for (const config of configs) {
|
|
801
|
-
try {
|
|
802
|
-
const robot = new PierRobot(config);
|
|
803
|
-
instances.set(config.accountId, robot);
|
|
804
|
-
await robot.start();
|
|
805
|
-
} catch (err) {
|
|
806
|
-
logger.error(`[pier-connector] ✖ Failed to start robot ${config.accountId}: ${err.message}`);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
},
|
|
810
|
-
|
|
811
|
-
stop: async () => {
|
|
812
|
-
logger.info('[pier-connector] 🛑 Stopping all robots …');
|
|
813
|
-
for (const [id, robot] of instances) {
|
|
814
|
-
try {
|
|
815
|
-
await robot.stop();
|
|
816
|
-
} catch (err) {
|
|
817
|
-
logger.error(`[pier-connector] ✖ Error stopping robot ${id}: ${err.message}`);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
instances.clear();
|
|
821
|
-
logger.info('[pier-connector] ✔ All robots stopped');
|
|
822
|
-
},
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
// ── 3. Register pier_publish tool ──────────────────────────────────
|
|
826
|
-
|
|
827
|
-
api.registerTool(
|
|
828
|
-
{
|
|
829
|
-
name: 'pier_publish',
|
|
830
|
-
description: 'Publish a task to the Pier job marketplace.',
|
|
831
|
-
parameters: {
|
|
832
|
-
type: 'object',
|
|
833
|
-
properties: {
|
|
834
|
-
task: { type: 'string', description: 'The task description' },
|
|
835
|
-
accountId: { type: 'string', description: 'Account to use (default: "default")' },
|
|
836
|
-
meta: { type: 'object' },
|
|
837
|
-
timeoutMs: { type: 'number' },
|
|
838
|
-
},
|
|
839
|
-
required: ['task'],
|
|
840
|
-
},
|
|
841
|
-
|
|
842
|
-
async execute(_id, params) {
|
|
843
|
-
const accountId = params.accountId || 'default';
|
|
844
|
-
const robot = instances.get(accountId) || instances.values().next().value;
|
|
845
|
-
if (!robot || robot.connectionStatus !== 'connected') {
|
|
846
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Robot not connected' }) }] };
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const timeout = params.timeoutMs || 60000;
|
|
850
|
-
const taskPayload = createRequestPayload({ task: params.task, timeoutMs: timeout, meta: params.meta });
|
|
851
|
-
|
|
852
|
-
try {
|
|
853
|
-
const reply = await robot.nc.request(robot.config.publishSubject, new TextEncoder().encode(JSON.stringify(taskPayload)), { timeout });
|
|
854
|
-
const resultData = JSON.parse(new TextDecoder().decode(reply.data));
|
|
855
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, taskId: taskPayload.id, result: resultData }) }] };
|
|
856
|
-
} catch (err) {
|
|
857
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] };
|
|
858
|
-
}
|
|
859
|
-
},
|
|
860
|
-
},
|
|
861
|
-
{ optional: true },
|
|
862
|
-
);
|
|
863
|
-
|
|
864
|
-
api.registerTool(
|
|
865
|
-
{
|
|
866
|
-
name: 'pier_chat',
|
|
867
|
-
description: 'Send a message to the employer regarding a specific job.',
|
|
868
|
-
parameters: {
|
|
869
|
-
type: 'object',
|
|
870
|
-
properties: {
|
|
871
|
-
jobId: { type: 'string' },
|
|
872
|
-
text: { type: 'string' },
|
|
873
|
-
accountId: { type: 'string' }
|
|
874
|
-
},
|
|
875
|
-
required: ['jobId', 'text']
|
|
876
|
-
},
|
|
877
|
-
async execute(_id, params, ctx) {
|
|
878
|
-
const accountId = params.accountId || 'default';
|
|
879
|
-
logger.info(`[pier-connector] 🛠️ Tool called: pier_chat | jobId=${params.jobId} | accountId=${accountId}`);
|
|
880
|
-
const robot = instances.get(accountId) || instances.values().next().value;
|
|
881
|
-
if (!robot || robot.connectionStatus !== 'connected') {
|
|
882
|
-
return { content: [{ type: 'text', text: 'Error: Robot not connected' }] };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
try {
|
|
886
|
-
const subject = `chat.${params.jobId}`;
|
|
887
|
-
let metadata = robot.activeNodeJobs.get(params.jobId);
|
|
888
|
-
|
|
889
|
-
if (!metadata && ctx.to) {
|
|
890
|
-
const toId = ctx.to.replace(/^pier:/, '');
|
|
891
|
-
metadata = robot.activeNodeJobs.get(toId);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const jobId = metadata?.pierJobId || params.jobId;
|
|
895
|
-
|
|
896
|
-
const payload = {
|
|
897
|
-
id: crypto.randomUUID ? crypto.randomUUID() : (Math.random().toString(36).substring(2)),
|
|
898
|
-
job_id: jobId,
|
|
899
|
-
sender_id: robot.config.nodeId,
|
|
900
|
-
sender_name: accountId,
|
|
901
|
-
sender_type: 'node',
|
|
902
|
-
content: params.text,
|
|
903
|
-
created_at: new Date().toISOString(),
|
|
904
|
-
auth_token: robot.config.secretKey
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
|
|
908
|
-
return { content: [{ type: 'text', text: 'Message sent' }] };
|
|
909
|
-
} catch (err) {
|
|
910
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
},
|
|
914
|
-
{ optional: true }
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
// ── V2 System Action Tools ────────────────────────────────────────
|
|
918
|
-
|
|
919
|
-
const registerSystemActionTool = (name, description, action, extraParams, userRole = 'node') => {
|
|
920
|
-
api.registerTool({
|
|
921
|
-
name,
|
|
922
|
-
description,
|
|
923
|
-
parameters: {
|
|
924
|
-
type: 'object',
|
|
925
|
-
properties: {
|
|
926
|
-
jobId: { type: 'string', description: 'The ID of the job/chat session' },
|
|
927
|
-
accountId: { type: 'string' },
|
|
928
|
-
...extraParams
|
|
929
|
-
},
|
|
930
|
-
required: ['jobId', ...Object.keys(extraParams)]
|
|
931
|
-
},
|
|
932
|
-
async execute(_id, params) {
|
|
933
|
-
const accountId = params.accountId || 'default';
|
|
934
|
-
const robot = instances.get(accountId) || instances.values().next().value;
|
|
935
|
-
if (!robot || robot.connectionStatus !== 'connected') {
|
|
936
|
-
return { content: [{ type: 'text', text: 'Error: Robot not connected' }] };
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
try {
|
|
940
|
-
const subject = `chat.${params.jobId}`;
|
|
941
|
-
const { jobId, accountId: _, ...payload } = params;
|
|
942
|
-
|
|
943
|
-
const msgData = {
|
|
944
|
-
id: crypto.randomUUID ? crypto.randomUUID() : (Math.random().toString(36).substring(2)),
|
|
945
|
-
job_id: params.jobId,
|
|
946
|
-
sender_id: userRole === 'user' ? 'user_' + robot.config.nodeId : robot.config.nodeId,
|
|
947
|
-
sender_type: userRole,
|
|
948
|
-
content: JSON.stringify({ type: 'system_action', action, payload }),
|
|
949
|
-
created_at: new Date().toISOString(),
|
|
950
|
-
auth_token: robot.config.secretKey,
|
|
951
|
-
type: 'system_action',
|
|
952
|
-
action: action
|
|
953
|
-
};
|
|
954
|
-
|
|
955
|
-
await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(msgData)));
|
|
956
|
-
return { content: [{ type: 'text', text: `${action} executed successfully` }] };
|
|
957
|
-
} catch (err) {
|
|
958
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}, { optional: true });
|
|
962
|
-
};
|
|
963
|
-
|
|
964
|
-
// For Node (Worker)
|
|
965
|
-
registerSystemActionTool('pier_bid_task', 'Bid on an open marketplace task to offer your services to the employer', 'task_bid', { message: { type: 'string', description: 'Your pitch explaining why you are a good fit for this task' } });
|
|
966
|
-
registerSystemActionTool('pier_accept_task', 'Accept an offered task from the employer in the current chat', 'task_accept', {});
|
|
967
|
-
registerSystemActionTool('pier_finish_task', 'Submit the final result for a task', 'task_submit', { result: { type: 'string', description: 'The final result or file links' } });
|
|
968
|
-
|
|
969
|
-
// For User (Employer Robot) - Spoofed identity using Node's config for A2A testing
|
|
970
|
-
registerSystemActionTool('pier_propose_task', 'Offer a task to a node with a specific price', 'task_offer', {
|
|
971
|
-
price: { type: 'number', description: 'The price in PIER tokens to offer' },
|
|
972
|
-
description: { type: 'string', description: 'The formal task description' }
|
|
973
|
-
}, 'user');
|
|
974
|
-
|
|
975
|
-
registerSystemActionTool('pier_rate_task', 'Rate the node after completion', 'task_rate', {
|
|
976
|
-
score: { type: 'number', description: 'Rating from 1 to 5' },
|
|
977
|
-
comment: { type: 'string', description: 'A short review' }
|
|
978
|
-
}, 'user');
|
|
979
|
-
|
|
980
|
-
registerSystemActionTool('pier_reject_task', 'Reject an offered task from the employer in the current chat', 'task_reject', {
|
|
981
|
-
reason: { type: 'string', description: 'Reason for rejection' }
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
registerSystemActionTool('pier_fail_task', 'Report that the task has failed during execution', 'task_error', {
|
|
985
|
-
error: { type: 'string', description: 'Description of the error' }
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
registerSystemActionTool('pier_cancel_task', 'Cancel a previously proposed task', 'task_cancel', {
|
|
989
|
-
reason: { type: 'string', description: 'Reason for cancellation' }
|
|
990
|
-
}, 'user');
|
|
991
|
-
|
|
992
|
-
api.registerTool({
|
|
993
|
-
name: 'pier_get_profile',
|
|
994
|
-
description: 'Get the current Pier profile, balance, and node stats.',
|
|
995
|
-
parameters: {
|
|
996
|
-
type: 'object',
|
|
997
|
-
properties: {
|
|
998
|
-
accountId: { type: 'string' }
|
|
999
|
-
}
|
|
1000
|
-
},
|
|
1001
|
-
async execute(_id, params) {
|
|
1002
|
-
const accountId = params.accountId || 'default';
|
|
1003
|
-
const robot = instances.get(accountId) || instances.values().next().value;
|
|
1004
|
-
if (!robot) return { content: [{ type: 'text', text: 'Error: Robot not found' }] };
|
|
1005
|
-
|
|
1006
|
-
try {
|
|
1007
|
-
const profile = await robot.client.getUserProfile(robot.config.secretKey);
|
|
1008
|
-
return { content: [{ type: 'text', text: JSON.stringify(profile, null, 2) }] };
|
|
1009
|
-
} catch (err) {
|
|
1010
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}, { optional: true });
|
|
1014
|
-
|
|
1015
|
-
// ── 4. Register /pier status command ───────────────────────────────
|
|
1016
|
-
|
|
1017
|
-
api.registerCommand({
|
|
1018
|
-
name: 'pier',
|
|
1019
|
-
description: 'Show Pier connector status',
|
|
1020
|
-
handler: () => {
|
|
1021
|
-
if (instances.size === 0) return { text: 'No Pier robots active.' };
|
|
1022
|
-
|
|
1023
|
-
const lines = ['**Pier Connector Status**'];
|
|
1024
|
-
for (const [id, robot] of instances) {
|
|
1025
|
-
const uptime = robot.connectedAt
|
|
1026
|
-
? `${Math.round((Date.now() - robot.connectedAt.getTime()) / 1000)}s`
|
|
1027
|
-
: 'N/A';
|
|
1028
|
-
|
|
1029
|
-
lines.push(`\n**Account: ${id}**`);
|
|
1030
|
-
lines.push(`• Connection: ${robot.connectionStatus}`);
|
|
1031
|
-
lines.push(`• Node ID: ${robot.config.nodeId || 'N/A'}`);
|
|
1032
|
-
lines.push(`• Uptime: ${uptime}`);
|
|
1033
|
-
lines.push(`• Jobs: ${robot.stats.received} received, ${robot.stats.completed} completed, ${robot.stats.failed} failed`);
|
|
1034
|
-
if (robot.lastHeartbeatError) {
|
|
1035
|
-
lines.push(`• \x1b[31mHeartbeat Error: ${robot.lastHeartbeatError}\x1b[0m`);
|
|
1036
|
-
} else {
|
|
1037
|
-
lines.push(`• Heartbeat: OK`);
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
lines.push(`\n**Global Stats:** ${totalJobsReceived} total jobs received`);
|
|
1042
|
-
|
|
1043
|
-
return { text: lines.join('\n') };
|
|
1044
|
-
},
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
// ── 6. Register CLI Setup Command ──────────────────────────────────
|
|
1048
|
-
|
|
1049
|
-
api.registerCli(
|
|
1050
|
-
({ program }) => {
|
|
1051
|
-
// Find or create the 'pier' command namespace to avoid root conflicts
|
|
1052
|
-
let pier = program.commands.find(c => c.name() === 'pier');
|
|
1053
|
-
if (!pier) {
|
|
1054
|
-
pier = program.command('pier').description('Pier connector commands');
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
pier
|
|
1058
|
-
.command('setup')
|
|
1059
|
-
.description('Interactively configure the Pier connector settings')
|
|
1060
|
-
.action(async () => {
|
|
1061
|
-
const currentConfig = resolveConfig();
|
|
1062
|
-
|
|
1063
|
-
console.log('\n🚢 \x1b[1m\x1b[36mPier Connector Setup (V1.1)\x1b[0m');
|
|
1064
|
-
console.log('You can register manually or let the Auto-Host (Advanced) feature do it for you using a Wallet Private Key.\n');
|
|
1065
|
-
|
|
1066
|
-
let setupMethod = 'manual';
|
|
1067
|
-
try {
|
|
1068
|
-
const answer = await inquirer.prompt([
|
|
1069
|
-
{
|
|
1070
|
-
type: 'list',
|
|
1071
|
-
name: 'method',
|
|
1072
|
-
message: 'How would you like to configure your node credentials?',
|
|
1073
|
-
choices: [
|
|
1074
|
-
{ name: 'Manual Entry (Recommended) - I have a Node ID and Secret Key', value: 'manual' },
|
|
1075
|
-
{ name: 'Auto-Host (Advanced) - Register automatically using my Wallet Private Key', value: 'auto' }
|
|
1076
|
-
],
|
|
1077
|
-
default: 'manual'
|
|
1078
|
-
}
|
|
1079
|
-
]);
|
|
1080
|
-
if (answer.method) {
|
|
1081
|
-
setupMethod = answer.method;
|
|
1082
|
-
}
|
|
1083
|
-
} catch (err) {
|
|
1084
|
-
console.log('\n\x1b[33mFalling back to Manual Entry mode due to terminal configuration.\x1b[0m');
|
|
1085
|
-
setupMethod = 'manual';
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
let finalNodeId = currentConfig.nodeId;
|
|
1089
|
-
let finalSecretKey = currentConfig.secretKey;
|
|
1090
|
-
let finalPrivateKey = currentConfig.privateKey;
|
|
1091
|
-
let finalWallet = currentConfig.walletAddress;
|
|
1092
|
-
|
|
1093
|
-
if (setupMethod === 'manual') {
|
|
1094
|
-
const manualAnswers = await inquirer.prompt([
|
|
1095
|
-
{ type: 'input', name: 'pierApiUrl', message: 'Pier API URL:', default: currentConfig.pierApiUrl },
|
|
1096
|
-
{ type: 'input', name: 'nodeId', message: 'Bot Node ID (UUID):', default: finalNodeId },
|
|
1097
|
-
{ type: 'password', name: 'secretKey', message: 'Bot Secret Key:', default: finalSecretKey },
|
|
1098
|
-
{ type: 'input', name: 'walletAddress', message: 'Your Wallet Address (for payout):', default: finalWallet },
|
|
1099
|
-
{ type: 'input', name: 'capabilities', message: 'Capabilities (comma-separated):', default: (currentConfig.capabilities || []).join(', ') }
|
|
1100
|
-
]);
|
|
1101
|
-
finalNodeId = manualAnswers.nodeId;
|
|
1102
|
-
finalSecretKey = manualAnswers.secretKey;
|
|
1103
|
-
finalWallet = manualAnswers.walletAddress;
|
|
1104
|
-
currentConfig.capabilities = manualAnswers.capabilities ? manualAnswers.capabilities.split(',').map(s => s.trim()) : currentConfig.capabilities;
|
|
1105
|
-
currentConfig.pierApiUrl = manualAnswers.pierApiUrl;
|
|
1106
|
-
} else if (setupMethod === 'auto') {
|
|
1107
|
-
const autoAnswers = await inquirer.prompt([
|
|
1108
|
-
{ type: 'input', name: 'pierApiUrl', message: 'Pier API URL:', default: currentConfig.pierApiUrl },
|
|
1109
|
-
{ type: 'password', name: 'privateKey', message: 'Your Wallet Private Key (Hex, 0x...):', default: finalPrivateKey },
|
|
1110
|
-
{ type: 'input', name: 'capabilities', message: 'Capabilities (comma-separated):', default: (currentConfig.capabilities || []).join(', ') }
|
|
1111
|
-
]);
|
|
1112
|
-
console.log('\n\x1b[36mRegistering your node...\x1b[0m');
|
|
1113
|
-
try {
|
|
1114
|
-
const tempClient = new PierClient({ apiUrl: autoAnswers.pierApiUrl });
|
|
1115
|
-
const hostName = api?.getRuntimeInfo?.()?.hostname ?? 'Auto-Node';
|
|
1116
|
-
|
|
1117
|
-
const { nodeId, secretKey, walletAddress } = await tempClient.autoRegister(autoAnswers.privateKey, hostName);
|
|
1118
|
-
|
|
1119
|
-
finalWallet = finalWallet || walletAddress;
|
|
1120
|
-
finalPrivateKey = autoAnswers.privateKey;
|
|
1121
|
-
currentConfig.pierApiUrl = autoAnswers.pierApiUrl;
|
|
1122
|
-
currentConfig.capabilities = autoAnswers.capabilities ? autoAnswers.capabilities.split(',').map(s => s.trim()) : currentConfig.capabilities;
|
|
1123
|
-
finalNodeId = nodeId;
|
|
1124
|
-
finalSecretKey = secretKey;
|
|
1125
|
-
|
|
1126
|
-
console.log(`\x1b[32m✔ Node registered automatically! Node ID: ${nodeId}\x1b[0m`);
|
|
1127
|
-
} catch (err) {
|
|
1128
|
-
console.error(`\x1b[31m✖ Failed to auto-register: ${err.message}\x1b[0m`);
|
|
1129
|
-
return; // Stop setup
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// ── Select Agent Binding ──
|
|
1134
|
-
let finalAgentId = currentConfig.agentId;
|
|
1135
|
-
try {
|
|
1136
|
-
let agentsInfo = [];
|
|
1137
|
-
if (api.runtime && typeof api.runtime.getAgents === 'function') {
|
|
1138
|
-
const agentsMap = await api.runtime.getAgents();
|
|
1139
|
-
if (agentsMap) {
|
|
1140
|
-
agentsInfo = Object.entries(agentsMap).map(([id, info]) => ({
|
|
1141
|
-
name: `${info.name || 'Unnamed Agent'} (${id})`,
|
|
1142
|
-
value: id
|
|
1143
|
-
}));
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
if (agentsInfo.length > 0) {
|
|
1148
|
-
agentsInfo.unshift({ name: 'None (Default Routing)', value: '' });
|
|
1149
|
-
const agentAnswer = await inquirer.prompt([
|
|
1150
|
-
{
|
|
1151
|
-
type: 'list',
|
|
1152
|
-
name: 'agentId',
|
|
1153
|
-
message: 'Select an Agent to bind for processing these tasks:',
|
|
1154
|
-
choices: agentsInfo,
|
|
1155
|
-
default: finalAgentId || ''
|
|
1156
|
-
}
|
|
1157
|
-
]);
|
|
1158
|
-
finalAgentId = agentAnswer.agentId;
|
|
1159
|
-
}
|
|
1160
|
-
} catch (err) {
|
|
1161
|
-
logger.warn(`Could not load agents list: ${err.message}`);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Determine how to save.
|
|
1165
|
-
console.log('\n✅ \x1b[32mConfiguration Captured!\x1b[0m\n');
|
|
1166
|
-
|
|
1167
|
-
const newConfigBlock = {
|
|
1168
|
-
pierApiUrl: currentConfig.pierApiUrl,
|
|
1169
|
-
nodeId: finalNodeId,
|
|
1170
|
-
secretKey: finalSecretKey,
|
|
1171
|
-
walletAddress: finalWallet,
|
|
1172
|
-
agentId: finalAgentId,
|
|
1173
|
-
capabilities: currentConfig.capabilities
|
|
1174
|
-
};
|
|
1175
|
-
|
|
1176
|
-
if (setupMethod === 'auto') {
|
|
1177
|
-
newConfigBlock.privateKey = finalPrivateKey;
|
|
1178
|
-
console.log('\x1b[33mNote: Private Key is included for future automatic reconnects.\x1b[0m');
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// Write to openclaw.json
|
|
1182
|
-
try {
|
|
1183
|
-
const cwd = process.cwd();
|
|
1184
|
-
const configPath = path.join(cwd, 'openclaw.json');
|
|
1185
|
-
let existingConfig = {};
|
|
1186
|
-
if (fs.existsSync(configPath)) {
|
|
1187
|
-
const raw = fs.readFileSync(configPath, 'utf8');
|
|
1188
|
-
existingConfig = JSON.parse(raw);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
if (!existingConfig.plugins) existingConfig.plugins = {};
|
|
1192
|
-
if (!existingConfig.plugins.entries) existingConfig.plugins.entries = {};
|
|
1193
|
-
if (!existingConfig.plugins.entries['pier-connector']) {
|
|
1194
|
-
existingConfig.plugins.entries['pier-connector'] = { enabled: true };
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
existingConfig.plugins.entries['pier-connector'].config = {
|
|
1198
|
-
...(existingConfig.plugins.entries['pier-connector'].config || {}),
|
|
1199
|
-
...newConfigBlock
|
|
1200
|
-
};
|
|
1201
|
-
|
|
1202
|
-
fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2), 'utf8');
|
|
1203
|
-
console.log(`\x1b[32m✔ Successfully wrote configuration to ${configPath}\x1b[0m`);
|
|
1204
|
-
} catch (err) {
|
|
1205
|
-
console.error(`\x1b[31m✖ Failed to write to openclaw.json automatically: ${err.message}\x1b[0m`);
|
|
1206
|
-
console.log('Please ensure your \x1b[1m\x1b[33mopenclaw.json\x1b[0m includes the following block in plugins.entries:\n');
|
|
1207
|
-
console.log(JSON.stringify({
|
|
1208
|
-
"pier-connector": {
|
|
1209
|
-
enabled: true,
|
|
1210
|
-
config: newConfigBlock
|
|
1211
|
-
}
|
|
1212
|
-
}, null, 2));
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
console.log('\nRestart OpenClaw (or reload plugins) to apply changes.');
|
|
1216
|
-
});
|
|
1217
|
-
},
|
|
1218
|
-
{ commands: ['pier'] }
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
logger.info('[pier-connector] Plugin registered');
|
|
1222
|
-
}
|