@gholl-studio/pier-connector 0.6.3 → 0.6.5

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/index.js ADDED
@@ -0,0 +1,644 @@
1
+ /**
2
+ * @file index.ts
3
+ * @description Main entry point for the Pier Connector plugin.
4
+ * Implements the standard OpenClaw ChannelPlugin interface for native multi-account support.
5
+ */
6
+ import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
7
+ import { protocol, PierCrypto } from '@gholl-studio/pier-sdk';
8
+ const { createRequestPayload } = protocol;
9
+ import { DEFAULTS } from './config.js';
10
+ import { PierRobot } from './robot.js';
11
+ import { handleInbound } from './inbound.js';
12
+ import { registerCli } from './cli.js';
13
+ // Global instances map to track active robots by account ID
14
+ const instances = new Map();
15
+ /**
16
+ * Merges global/legacy config with account-specific overrides.
17
+ */
18
+ function mergedCfgFrom(legacy, account) {
19
+ const merged = { ...legacy, ...account };
20
+ return {
21
+ accountId: account.accountId || 'default',
22
+ pierApiUrl: merged.pierApiUrl || DEFAULTS.PIER_API_URL,
23
+ nodeId: merged.nodeId || DEFAULTS.NODE_ID,
24
+ secretKey: merged.secretKey || DEFAULTS.SECRET_KEY,
25
+ privateKey: merged.privateKey || DEFAULTS.PRIVATE_KEY,
26
+ natsUrl: merged.natsUrl || DEFAULTS.NATS_URL,
27
+ subject: merged.subject || DEFAULTS.SUBJECT,
28
+ publishSubject: merged.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
29
+ queueGroup: merged.queueGroup || DEFAULTS.QUEUE_GROUP,
30
+ agentId: merged.agentId || DEFAULTS.AGENT_ID,
31
+ walletAddress: merged.walletAddress || DEFAULTS.WALLET_ADDRESS,
32
+ capabilities: merged.capabilities || ['translation', 'code-execution', 'reasoning', 'vision'],
33
+ };
34
+ }
35
+ /**
36
+ * Resolves all configured accounts for the Pier channel.
37
+ */
38
+ function getAccountConfigs(cfg, pluginConfig) {
39
+ const globalAccounts = cfg?.channels?.['pier']?.accounts || {};
40
+ const pluginAccounts = pluginConfig?.accounts || {};
41
+ const rawAccounts = { ...globalAccounts, ...pluginAccounts };
42
+ const legacyCfg = pluginConfig || {};
43
+ if (Object.keys(rawAccounts).length === 0) {
44
+ return [mergedCfgFrom(legacyCfg, { accountId: 'default' })];
45
+ }
46
+ return Object.entries(rawAccounts).map(([id, account]) => mergedCfgFrom(legacyCfg, { ...account, accountId: id }));
47
+ }
48
+ const pierPlugin = {
49
+ id: 'pier',
50
+ meta: {
51
+ id: 'pier',
52
+ label: 'Pier',
53
+ selectionLabel: 'Pier (NATS Job Marketplace)',
54
+ docsPath: '/channels/pier',
55
+ blurb: 'Connect to the Pier distributed job marketplace.',
56
+ },
57
+ capabilities: {
58
+ chatTypes: ['direct'],
59
+ media: false,
60
+ reactions: false,
61
+ threads: false,
62
+ nativeCommands: true,
63
+ blockStreaming: true,
64
+ },
65
+ // -------------------------------------------------------------------------
66
+ // Config Adapter
67
+ // -------------------------------------------------------------------------
68
+ config: {
69
+ listAccountIds: (cfg) => {
70
+ const accounts = cfg?.channels?.['pier']?.accounts || {};
71
+ const ids = Object.keys(accounts);
72
+ return ids.length > 0 ? ids : ['default'];
73
+ },
74
+ resolveAccount: (cfg, accountId) => {
75
+ const accounts = cfg?.channels?.['pier']?.accounts || {};
76
+ const account = accounts[accountId || 'default'] || {};
77
+ return mergedCfgFrom(cfg?.channels?.['pier'] || {}, { ...account, accountId: accountId || 'default' });
78
+ },
79
+ defaultAccountId: () => 'default'
80
+ },
81
+ // -------------------------------------------------------------------------
82
+ // Pairing Adapter
83
+ // -------------------------------------------------------------------------
84
+ pairing: {
85
+ idLabel: 'pierJobId',
86
+ normalizeAllowEntry: (entry) => entry.replace(/^(pier|job):/i, ''),
87
+ notifyApproval: async ({ cfg, id, accountId }) => {
88
+ // Signal received when 'openclaw pairing approve pier <id>' is run.
89
+ // Useful if we want to trigger specific onboarding or status updates.
90
+ console.log(`[pier-connector] Pairing approved for Job ${id} on account ${accountId}`);
91
+ }
92
+ },
93
+ // -------------------------------------------------------------------------
94
+ // Outbound Adapter
95
+ // -------------------------------------------------------------------------
96
+ outbound: {
97
+ deliveryMode: 'direct',
98
+ sendText: async (ctx) => {
99
+ const robot = instances.get(ctx.accountId || 'default') || instances.values().next().value;
100
+ if (!robot || !robot.js) {
101
+ throw new Error('Robot not connected');
102
+ }
103
+ const jobId = ctx.to.replace(/^pier:/, '');
104
+ const jobMeta = robot.activeNodeJobs.get(jobId);
105
+ const isWorkspace = jobMeta?.workspace === true || ctx.metadata?.workspace === true;
106
+ const subject = isWorkspace ? `workspace.${jobId}.chat` : `chat.${jobId}`;
107
+ const payload = {
108
+ id: globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : (Math.random().toString(36).substring(2)),
109
+ job_id: jobId,
110
+ sender_id: robot.config.nodeId,
111
+ sender_name: robot.accountId,
112
+ sender_type: 'node',
113
+ content: ctx.text,
114
+ created_at: new Date().toISOString(),
115
+ auth_token: robot.config.secretKey
116
+ };
117
+ await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
118
+ return {
119
+ channel: 'pier',
120
+ messageId: payload.id,
121
+ conversationId: jobId
122
+ };
123
+ }
124
+ },
125
+ // -------------------------------------------------------------------------
126
+ // Status Adapter
127
+ // -------------------------------------------------------------------------
128
+ status: {
129
+ buildAccountSnapshot: ({ account, cfg }) => {
130
+ const robot = instances.get(account.accountId);
131
+ return {
132
+ accountId: account.accountId,
133
+ running: !!robot,
134
+ connected: robot?.connectionStatus === 'connected',
135
+ lastStartAt: robot?.lastStartAt,
136
+ lastStopAt: robot?.lastStopAt,
137
+ lastError: robot?.lastError,
138
+ healthState: robot?.connectionStatus === 'connected' ? 'healthy' : 'degraded',
139
+ };
140
+ },
141
+ resolveAccountState: ({ configured, enabled }) => {
142
+ return configured && enabled ? 'enabled' : 'disabled';
143
+ }
144
+ },
145
+ // -------------------------------------------------------------------------
146
+ // Gateway Adapter
147
+ // -------------------------------------------------------------------------
148
+ gateway: {
149
+ startAccount: async (ctx) => {
150
+ const config = ctx.account;
151
+ // Use ctx as the runtime provider as it contains .channelRuntime, .log, etc.
152
+ const robot = new PierRobot(config, ctx, async (inbound, jobId) => {
153
+ // Pass the context containing channelRuntime
154
+ await handleInbound(ctx, inbound, jobId, robot, pierPlugin);
155
+ });
156
+ instances.set(ctx.accountId, robot);
157
+ try {
158
+ robot.lastStartAt = Date.now();
159
+ await robot.start();
160
+ ctx.setStatus({
161
+ ...ctx.getStatus(),
162
+ running: true,
163
+ connected: true,
164
+ lastStartAt: robot.lastStartAt
165
+ });
166
+ // IMPORTANT: Keep startAccount promise active for the lifetime of the connection.
167
+ // If this promise resolves, the Gateway will auto-restart the account.
168
+ await new Promise((resolve) => {
169
+ ctx.abortSignal.addEventListener('abort', () => {
170
+ resolve();
171
+ }, { once: true });
172
+ if (ctx.abortSignal.aborted)
173
+ resolve();
174
+ });
175
+ console.log(`[pier-connector][${ctx.accountId}] Account signal aborted. Stopping...`);
176
+ }
177
+ catch (err) {
178
+ robot.lastError = err.message;
179
+ ctx.setStatus({
180
+ ...ctx.getStatus(),
181
+ running: false,
182
+ lastError: err.message
183
+ });
184
+ throw err;
185
+ }
186
+ },
187
+ stopAccount: async (ctx) => {
188
+ const robot = instances.get(ctx.accountId);
189
+ if (robot) {
190
+ robot.lastStopAt = Date.now();
191
+ await robot.stop();
192
+ instances.delete(ctx.accountId);
193
+ }
194
+ ctx.setStatus({
195
+ ...ctx.getStatus(),
196
+ running: false,
197
+ lastStopAt: robot.lastStopAt
198
+ });
199
+ }
200
+ }
201
+ };
202
+ const register = (api) => {
203
+ const logger = api.logger;
204
+ const globalStats = { received: 0, completed: 0, failed: 0 };
205
+ // Register our new ChannelPlugin
206
+ api.registerChannel({ plugin: pierPlugin });
207
+ // Tools and CLI registration
208
+ api.registerTool({
209
+ name: 'pier_publish',
210
+ label: 'Publish to Pier',
211
+ description: 'Publish a task to the Pier job marketplace.',
212
+ parameters: {
213
+ type: 'object',
214
+ properties: {
215
+ task: { type: 'string' },
216
+ accountId: { type: 'string' }
217
+ },
218
+ required: ['task']
219
+ },
220
+ async execute(_id, params, ctx) {
221
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
222
+ const robot = instances.get(accountId) || instances.values().next().value;
223
+ if (!robot || robot.connectionStatus !== 'connected') {
224
+ return {
225
+ content: [{ type: 'text', text: 'Robot not connected' }],
226
+ details: {}
227
+ };
228
+ }
229
+ const taskPayload = createRequestPayload({ task: params.task });
230
+ const reply = await robot.nc.request(robot.config.publishSubject, new TextEncoder().encode(JSON.stringify(taskPayload)));
231
+ return {
232
+ content: [{ type: 'text', text: new TextDecoder().decode(reply.data) }],
233
+ details: {}
234
+ };
235
+ }
236
+ }, { optional: true });
237
+ api.registerTool({
238
+ name: 'pier_chat',
239
+ label: 'Pier Chat',
240
+ description: 'Send a message to the employer regarding a specific job.',
241
+ parameters: {
242
+ type: 'object',
243
+ properties: {
244
+ jobId: { type: 'string' },
245
+ text: { type: 'string' },
246
+ accountId: { type: 'string' }
247
+ },
248
+ required: ['jobId', 'text']
249
+ },
250
+ async execute(_id, params, ctx) {
251
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
252
+ const robot = instances.get(accountId) || instances.values().next().value;
253
+ if (!robot || robot.connectionStatus !== 'connected') {
254
+ return { content: [{ type: 'text', text: 'Error: Robot not connected' }], details: {} };
255
+ }
256
+ try {
257
+ const subject = `chat.${params.jobId}`;
258
+ let metadata = robot.activeNodeJobs.get(params.jobId);
259
+ if (!metadata && ctx.to) {
260
+ const toId = ctx.to.replace(/^pier:/, '');
261
+ metadata = robot.activeNodeJobs.get(toId);
262
+ }
263
+ const jobId = metadata?.pierJobId || params.jobId;
264
+ if (!robot.js) {
265
+ return { content: [{ type: 'text', text: 'Error: JetStream not available' }], details: {} };
266
+ }
267
+ const payload = {
268
+ id: globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : (Math.random().toString(36).substring(2)),
269
+ job_id: jobId,
270
+ sender_id: robot.config.nodeId,
271
+ sender_name: accountId,
272
+ sender_type: 'node',
273
+ content: params.text,
274
+ created_at: new Date().toISOString(),
275
+ auth_token: robot.config.secretKey
276
+ };
277
+ await robot.js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
278
+ return { content: [{ type: 'text', text: 'Message sent' }], details: {} };
279
+ }
280
+ catch (err) {
281
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
282
+ }
283
+ }
284
+ }, { optional: true });
285
+ // Helper to register marketplace action tools
286
+ const registerSystemActionTool = (name, label, description, action, extraParams, userRole = 'node') => {
287
+ api.registerTool({
288
+ name,
289
+ label,
290
+ description,
291
+ parameters: {
292
+ type: 'object',
293
+ properties: {
294
+ jobId: { type: 'string', description: 'The ID of the job' },
295
+ accountId: { type: 'string' },
296
+ ...extraParams
297
+ },
298
+ required: ['jobId', ...Object.keys(extraParams)]
299
+ },
300
+ async execute(_id, params, ctx) {
301
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
302
+ const robot = instances.get(accountId) || instances.values().next().value;
303
+ if (!robot || robot.connectionStatus !== 'connected') {
304
+ return { content: [{ type: 'text', text: 'Error: Robot not connected' }], details: {} };
305
+ }
306
+ try {
307
+ const { jobId: j, accountId: _, ...p } = params;
308
+ if (!robot.js) {
309
+ return { content: [{ type: 'text', text: 'Error: JetStream not available' }], details: {} };
310
+ }
311
+ const msgData = {
312
+ id: globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : (Math.random().toString(36).substring(2)),
313
+ job_id: params.jobId,
314
+ sender_id: userRole === 'user' ? 'user_' + robot.config.nodeId : robot.config.nodeId,
315
+ sender_type: userRole,
316
+ content: JSON.stringify({ type: 'system_action', action, payload: p }),
317
+ created_at: new Date().toISOString(),
318
+ auth_token: robot.config.secretKey,
319
+ type: 'system_action',
320
+ action: action
321
+ };
322
+ await robot.js.publish(`chat.${params.jobId}`, new TextEncoder().encode(JSON.stringify(msgData)));
323
+ return { content: [{ type: 'text', text: `${action} executed successfully` }], details: {} };
324
+ }
325
+ catch (err) {
326
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
327
+ }
328
+ }
329
+ }, { optional: true });
330
+ };
331
+ registerSystemActionTool('pier_bid_task', 'Bid on task', 'Bid on an marketplace task', 'task_bid', { message: { type: 'string', description: 'Your pitch' } });
332
+ registerSystemActionTool('pier_accept_task', 'Accept task', 'Accept offered task', 'task_accept', {});
333
+ registerSystemActionTool('pier_finish_task', 'Finish task', 'Submit final result', 'task_submit', { result: { type: 'string', description: 'Final result' } });
334
+ registerSystemActionTool('pier_propose_task', 'Offer task', 'Offer task to a node', 'task_offer', {
335
+ price: { type: 'number' },
336
+ description: { type: 'string' }
337
+ }, 'user');
338
+ registerSystemActionTool('pier_rate_task', 'Rate task', 'Rate the node', 'task_rate', {
339
+ score: { type: 'number' },
340
+ comment: { type: 'string' }
341
+ }, 'user');
342
+ registerSystemActionTool('pier_reject_task', 'Reject task', 'Reject task', 'task_reject', { reason: { type: 'string' } });
343
+ registerSystemActionTool('pier_fail_task', 'Report error', 'Report that the task has failed', 'task_error', { error: { type: 'string' } });
344
+ registerSystemActionTool('pier_cancel_task', 'Cancel task', 'Cancel the task', 'task_cancel', { reason: { type: 'string' } }, 'user');
345
+ api.registerTool({
346
+ name: 'pier_get_profile',
347
+ label: 'Pier Profile',
348
+ description: 'Get current Pier profile and node stats.',
349
+ parameters: {
350
+ type: 'object',
351
+ properties: { accountId: { type: 'string' } }
352
+ },
353
+ async execute(_id, params, ctx) {
354
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
355
+ const robot = instances.get(accountId) || instances.values().next().value;
356
+ if (!robot)
357
+ return { content: [{ type: 'text', text: 'Error: Robot not found' }], details: {} };
358
+ try {
359
+ const profile = await robot.client.getUserProfile(robot.config.secretKey);
360
+ return { content: [{ type: 'text', text: JSON.stringify(profile, null, 2) }], details: {} };
361
+ }
362
+ catch (err) {
363
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
364
+ }
365
+ }
366
+ }, { optional: true });
367
+ // Multi-Agent Workspace Tools
368
+ api.registerTool({
369
+ name: 'pier_create_workspace',
370
+ label: 'Create Workspace',
371
+ description: 'Creates a decentralized multi-agent workspace (group chat) and initializes its whiteboard.',
372
+ parameters: {
373
+ type: 'object',
374
+ properties: {
375
+ namespace: { type: 'string', description: 'Federation namespace. Default to "default" if omitted.' }
376
+ }
377
+ },
378
+ async execute(_id, params, ctx) {
379
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
380
+ const robot = instances.get(accountId) || instances.values().next().value;
381
+ if (!robot)
382
+ return { content: [{ type: 'text', text: 'Error: Robot not found' }], details: {} };
383
+ try {
384
+ const groupId = globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : (Math.random().toString(36).substring(2));
385
+ const kv = await robot.client.nats.getWorkspaceKV(groupId);
386
+ await kv.put('status', new TextEncoder().encode('Active Workspace Created'));
387
+ await kv.put('budget', new TextEncoder().encode('100'));
388
+ return { content: [{ type: 'text', text: `Workspace created successfully. Group ID: ${groupId}. Initial budget set to 100.` }], details: { groupId } };
389
+ }
390
+ catch (err) {
391
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
392
+ }
393
+ }
394
+ }, { optional: true });
395
+ api.registerTool({
396
+ name: 'pier_read_whiteboard',
397
+ label: 'Read Whiteboard',
398
+ description: 'Read the current shared state from the workspace whiteboard (NATS KV)',
399
+ parameters: {
400
+ type: 'object',
401
+ properties: {
402
+ groupId: { type: 'string', description: 'The Workspace Group ID' },
403
+ key: { type: 'string', description: 'The key to read' },
404
+ namespace: { type: 'string', description: 'Federation namespace' }
405
+ },
406
+ required: ['groupId', 'key']
407
+ },
408
+ async execute(_id, params, ctx) {
409
+ const robot = instances.get(params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
410
+ if (!robot)
411
+ return { content: [{ type: 'text', text: 'Error: Robot not found' }], details: {} };
412
+ try {
413
+ const kv = await robot.client.nats.getWorkspaceKV(params.groupId);
414
+ const entry = await kv.get(params.key);
415
+ if (!entry)
416
+ return { content: [{ type: 'text', text: '[Whiteboard: Key Not Found. Use revision 0 to update.]' }], details: { revision: 0 } };
417
+ const valueStr = new TextDecoder().decode(entry.value);
418
+ const result = JSON.stringify({ revision: entry.revision, value: valueStr });
419
+ return { content: [{ type: 'text', text: `Data retrieved:\n${result}\n(Supply this revision to pier_update_whiteboard)` }], details: { revision: entry.revision } };
420
+ }
421
+ catch (err) {
422
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
423
+ }
424
+ }
425
+ }, { optional: true });
426
+ api.registerTool({
427
+ name: 'pier_update_whiteboard',
428
+ label: 'Update Whiteboard',
429
+ description: 'Update the shared state on the workspace whiteboard using CAS (Compare-And-Swap). Provide expected_revision from read.',
430
+ parameters: {
431
+ type: 'object',
432
+ properties: {
433
+ groupId: { type: 'string' },
434
+ key: { type: 'string' },
435
+ value: { type: 'string' },
436
+ expected_revision: { type: 'number', description: 'The revision number from the last read (use 0 if key not found)' },
437
+ namespace: { type: 'string' }
438
+ },
439
+ required: ['groupId', 'key', 'value', 'expected_revision']
440
+ },
441
+ async execute(_id, params, ctx) {
442
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
443
+ if (!robot)
444
+ return { content: [{ type: 'text', text: 'Robot not found' }], details: {} };
445
+ try {
446
+ const kv = await robot.client.nats.getWorkspaceKV(params.groupId);
447
+ try {
448
+ // if expected_revision is 0 it means we expect the key does not exist yet (or we use create)
449
+ if (params.expected_revision === 0) {
450
+ try {
451
+ await kv.create(params.key, new TextEncoder().encode(params.value));
452
+ }
453
+ catch (e) {
454
+ if (e.message?.includes('wrong last sequence'))
455
+ throw e; // Pass to conflict handler
456
+ await kv.update(params.key, new TextEncoder().encode(params.value), 0); // fallback
457
+ }
458
+ }
459
+ else {
460
+ await kv.update(params.key, new TextEncoder().encode(params.value), params.expected_revision);
461
+ }
462
+ return { content: [{ type: 'text', text: `Whiteboard key '${params.key}' updated successfully.` }], details: {} };
463
+ }
464
+ catch (updateErr) {
465
+ if (updateErr.message && (updateErr.message.includes('wrong last sequence') || updateErr.code === '400' || updateErr.api_error?.err_code === 10071)) {
466
+ return { content: [{ type: 'text', text: `ERROR: CAS Conflict - Another agent modified this key while you were working. Please use pier_read_whiteboard to get the latest revision and try again.` }], details: {} };
467
+ }
468
+ throw updateErr;
469
+ }
470
+ }
471
+ catch (err) {
472
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
473
+ }
474
+ }
475
+ }, { optional: true });
476
+ api.registerTool({
477
+ name: 'pier_group_mention',
478
+ label: 'Mention in Workspace',
479
+ description: 'Send a message in the workspace group chat explicitly mentioning an agent to wake them up',
480
+ parameters: {
481
+ type: 'object',
482
+ properties: {
483
+ groupId: { type: 'string' },
484
+ mentions: { type: 'array', items: { type: 'string' }, description: 'Array of target Agent Node IDs' },
485
+ content: { type: 'string', description: 'Message content' },
486
+ namespace: { type: 'string' }
487
+ },
488
+ required: ['groupId', 'mentions', 'content']
489
+ },
490
+ async execute(_id, params, ctx) {
491
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
492
+ if (!robot)
493
+ return { content: [{ type: 'text', text: 'Robot not found' }], details: {} };
494
+ try {
495
+ const msgId = globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : (Math.random().toString(36).substring(2));
496
+ const ns = params.namespace || 'default';
497
+ let payload = {
498
+ version: 'psp-1.0',
499
+ id: msgId,
500
+ namespace: ns,
501
+ timestamp: Date.now(),
502
+ senderId: robot.accountId,
503
+ groupId: params.groupId,
504
+ content: params.content,
505
+ mentions: params.mentions
506
+ };
507
+ if (robot.config.privateKey) {
508
+ payload = await PierCrypto.signSwarmMessage(payload, robot.config.privateKey);
509
+ }
510
+ await robot.client.nats.publishGroupChat(params.groupId, payload, ns);
511
+ return { content: [{ type: 'text', text: `Message sent to workspace ${params.groupId} successfully.` }], details: {} };
512
+ }
513
+ catch (err) {
514
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
515
+ }
516
+ }
517
+ }, { optional: true });
518
+ api.registerTool({
519
+ name: 'pier_search_workers',
520
+ label: 'Search Workers',
521
+ description: 'Find active agents on the Pier network. When \`capability\` is provided, uses fast Subject-based Discovery (PSP 1.0). Falls back to KV full-scan if omitted.',
522
+ parameters: {
523
+ type: 'object',
524
+ properties: {
525
+ capability: { type: 'string', description: '(Recommended) Filter by a specific capability e.g. "translation", "code-execution". Uses fast per-subject discovery when set.' }
526
+ }
527
+ },
528
+ async execute(_id, params, ctx) {
529
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
530
+ if (!robot)
531
+ return { content: [{ type: 'text', text: 'Error' }], details: {} };
532
+ // --- PSP 1.0 Subject-based Discovery (fast path, used when capability is specified) ---
533
+ if (params.capability) {
534
+ try {
535
+ const nodes = await robot.client.nats.queryDiscovery(params.capability);
536
+ return { content: [{ type: 'text', text: JSON.stringify(nodes, null, 2) }], details: {} };
537
+ }
538
+ catch (e) {
539
+ // Fall through to KV scan
540
+ }
541
+ }
542
+ // --- KV Full-scan (fallback when no capability filter, or subject discovery fails) ---
543
+ try {
544
+ const kv = await robot.client.nats.getWorkspaceKV('active_nodes');
545
+ const activeNodes = [];
546
+ const iter = await kv.keys();
547
+ for await (const k of iter) {
548
+ const entry = await kv.get(k);
549
+ if (entry) {
550
+ try {
551
+ // S10: DoS protection
552
+ if (entry.value.length > 1024 * 1024)
553
+ continue;
554
+ const nodeData = JSON.parse(new TextDecoder().decode(entry.value));
555
+ if (Date.now() - nodeData.timestamp < 120000) {
556
+ // --- Zero-Trust: Verify signed heartbeat (PSP 1.0 Hard-Block) ---
557
+ if (nodeData.version === 'psp-1.0' && nodeData.signature && nodeData.publicKey) {
558
+ const isValid = PierCrypto.verifySwarmMessage(nodeData);
559
+ if (!isValid) {
560
+ console.warn(`[pier_search_workers] šŸ”“ Dropping node ${k}: invalid signature (spoofed identity).`);
561
+ continue;
562
+ }
563
+ }
564
+ activeNodes.push(nodeData);
565
+ }
566
+ }
567
+ catch (e) { }
568
+ }
569
+ }
570
+ return { content: [{ type: 'text', text: JSON.stringify(activeNodes.slice(0, 10), null, 2) }], details: {} };
571
+ }
572
+ catch (e) {
573
+ // Final fallback: central API
574
+ try {
575
+ const resp = await fetch(`${robot.client.apiUrl}/nodes`);
576
+ const nodes = await resp.json();
577
+ const active = nodes.filter((n) => n.is_online).slice(0, 10);
578
+ return { content: [{ type: 'text', text: JSON.stringify(active, null, 2) }], details: {} };
579
+ }
580
+ catch (fallbackErr) {
581
+ return { content: [{ type: 'text', text: `All discovery methods failed: ${e.message}` }], details: {} };
582
+ }
583
+ }
584
+ }
585
+ }, { optional: true });
586
+ api.registerTool({
587
+ name: 'pier_invite_collaborator',
588
+ label: 'Invite to Workspace',
589
+ description: 'Send a targeted job request to a node to invite them.',
590
+ parameters: {
591
+ type: 'object',
592
+ properties: {
593
+ nodeId: { type: 'string' },
594
+ groupId: { type: 'string' },
595
+ roleDescription: { type: 'string' },
596
+ namespace: { type: 'string' }
597
+ },
598
+ required: ['nodeId', 'groupId', 'roleDescription']
599
+ },
600
+ async execute(_id, params, ctx) {
601
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
602
+ if (!robot)
603
+ return { content: [{ type: 'text', text: 'Error' }], details: {} };
604
+ try {
605
+ let req = createRequestPayload({
606
+ task: `INVITATION: Join my workspace ${params.groupId}.\nRole: ${params.roleDescription}\nPlease use 'pier_group_mention' tool to reply in group ${params.groupId}.\nYou can also use 'pier_read_whiteboard' with groupId=${params.groupId} to view the plan.`,
607
+ targetNodeId: params.nodeId,
608
+ namespace: params.namespace || 'default',
609
+ senderId: robot.accountId,
610
+ meta: { sender: robot.accountId, workspace: params.groupId }
611
+ });
612
+ if (robot.config.privateKey) {
613
+ req = await PierCrypto.signSwarmMessage(req, robot.config.privateKey);
614
+ }
615
+ await robot.js.publish(`jobs.node.${params.nodeId}`, new TextEncoder().encode(JSON.stringify(req)));
616
+ return { content: [{ type: 'text', text: `Invitation sent to ${params.nodeId}` }], details: {} };
617
+ }
618
+ catch (e) {
619
+ return { content: [{ type: 'text', text: e.message }], details: {} };
620
+ }
621
+ }
622
+ }, { optional: true });
623
+ // Status Command
624
+ api.registerCommand({
625
+ name: 'pier',
626
+ description: 'Show Pier status',
627
+ handler: () => {
628
+ const lines = ['**Pier Connector Status**'];
629
+ instances.forEach((r, id) => {
630
+ lines.push(`\n**Account: ${id}**\n• Status: ${r.connectionStatus}\n• Jobs: ${r.stats.received}/${r.stats.completed}/${r.stats.failed}`);
631
+ });
632
+ return { text: lines.join('\n') };
633
+ }
634
+ });
635
+ registerCli(api, globalStats);
636
+ logger.info('[pier-connector] Plugin registered');
637
+ };
638
+ export default definePluginEntry({
639
+ id: 'pier-connector',
640
+ name: 'Pier Connector',
641
+ description: 'Connects OpenClaw to the Pier job marketplace.',
642
+ register
643
+ });
644
+ //# sourceMappingURL=index.js.map