@gholl-studio/pier-connector 0.0.7 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gholl-studio/pier-connector",
3
3
  "author": "gholl",
4
- "version": "0.0.7",
4
+ "version": "0.1.0",
5
5
  "description": "OpenClaw plugin that connects to the Pier job marketplace. Automatically fetches, executes, and reports distributed tasks for rewards.",
6
6
  "type": "module",
7
7
  "main": "src/index.js",
@@ -31,9 +31,11 @@
31
31
  ],
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
+ "@nats-io/jetstream": "^3.3.1",
34
35
  "@nats-io/transport-node": "^3.0.0",
35
36
  "ethers": "^6.16.0",
36
- "inquirer": "^13.3.0"
37
+ "inquirer": "^13.3.0",
38
+ "pako": "^2.1.0"
37
39
  },
38
40
  "peerDependencies": {
39
41
  "openclaw": ">=2.0.0"
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Simple pass-through utility for messaging.
3
+ * Compression was removed for simplicity and ease of debugging.
4
+ */
5
+
6
+ export function compress(text) {
7
+ return text;
8
+ }
9
+
10
+ export function decompress(content) {
11
+ return content;
12
+ }
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import { createNatsConnection, drainConnection } from './nats-client.js';
12
12
  import { parseJob, safeRespond, truncate } from './job-handler.js';
13
13
  import { createRequestPayload, createResultPayload, createErrorPayload } from './protocol.js';
14
14
  import { DEFAULTS } from './config.js';
15
+ import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy } from '@nats-io/jetstream';
15
16
  import inquirer from 'inquirer';
16
17
  import { ethers } from 'ethers';
17
18
  import fs from 'fs';
@@ -29,6 +30,12 @@ export default function register(api) {
29
30
 
30
31
  /** @type {import('@nats-io/transport-node').NatsConnection | null} */
31
32
  let nc = null;
33
+
34
+ /** @type {import('@nats-io/transport-node').JetStreamContext | null} */
35
+ let js = null;
36
+
37
+ /** @type {any | null} */
38
+ let jsm = null;
32
39
 
33
40
  /** @type {import('@nats-io/transport-node').Subscription | null} */
34
41
  let subscription = null;
@@ -39,12 +46,17 @@ export default function register(api) {
39
46
  let jobsCompleted = 0;
40
47
  let jobsFailed = 0;
41
48
  let connectedAt = null;
49
+
50
+ /** @type {Map<string, import('@nats-io/transport-node').Subscription>} */
51
+ const jobSubscriptions = new Map();
52
+ const jobStopTimeouts = new Map();
42
53
 
43
54
  // ── resolve plugin config ──────────────────────────────────────────
44
55
 
45
56
  function resolveConfig() {
46
57
  const rawAccounts = api.config?.channels?.['pier']?.accounts || {};
47
- const firstAccount = Object.values(rawAccounts)[0] || {};
58
+ const accountId = Object.keys(rawAccounts)[0] || 'default';
59
+ const firstAccount = rawAccounts[accountId] || {};
48
60
 
49
61
  const legacyCfg = api.config?.plugins?.entries?.['pier-connector']?.config || {};
50
62
 
@@ -54,12 +66,13 @@ export default function register(api) {
54
66
  };
55
67
 
56
68
  return {
69
+ accountId: accountId,
57
70
  pierApiUrl: mergedCfg.pierApiUrl || DEFAULTS.PIER_API_URL,
58
71
  nodeId: mergedCfg.nodeId || DEFAULTS.NODE_ID,
59
72
  secretKey: mergedCfg.secretKey || DEFAULTS.SECRET_KEY,
60
73
  privateKey: mergedCfg.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
61
74
  natsUrl: mergedCfg.natsUrl || DEFAULTS.NATS_URL,
62
- subject: mergedCfg.subject || DEFAULTS.PIER_SUBJECT,
75
+ subject: mergedCfg.subject || DEFAULTS.SUBJECT,
63
76
  publishSubject: mergedCfg.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
64
77
  queueGroup: mergedCfg.queueGroup || DEFAULTS.QUEUE_GROUP,
65
78
  agentId: mergedCfg.agentId || DEFAULTS.AGENT_ID,
@@ -138,6 +151,12 @@ export default function register(api) {
138
151
  },
139
152
 
140
153
  setup: {
154
+ checkAccountStatus: async ({ accountId }) => {
155
+ if (connectionStatus === 'connected') return { status: 'connected' };
156
+ if (connectionStatus === 'error') return { status: 'error', details: 'NATS connection error' };
157
+ if (connectionStatus === 'connecting') return { status: 'connecting' };
158
+ return { status: 'disconnected', details: connectionStatus };
159
+ },
141
160
  applyAccountConfig: ({ cfg, accountId, input }) => {
142
161
  const draft = structuredClone(cfg);
143
162
  draft.channels = draft.channels || {};
@@ -172,8 +191,9 @@ export default function register(api) {
172
191
  sendText: async ({ text, metadata }) => {
173
192
  const jobId = metadata?.pierJobId;
174
193
  const msg = metadata?.pierNatsMsg;
194
+ const isRealtimeMsg = metadata?.isRealtimeMsg;
175
195
 
176
- if (msg) {
196
+ if (msg || isRealtimeMsg) {
177
197
  const elapsed = metadata?.pierStartTime
178
198
  ? (performance.now() - metadata.pierStartTime).toFixed(1)
179
199
  : null;
@@ -186,13 +206,48 @@ export default function register(api) {
186
206
  walletAddress: getActiveConfig().walletAddress,
187
207
  });
188
208
 
189
- safeRespond(msg, responsePayload);
209
+ if (isRealtimeMsg && js) {
210
+ try {
211
+ const config = getActiveConfig();
212
+ const replySubject = `jobs.job.${jobId}.msg`;
213
+ await js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
214
+ sender_id: config.nodeId,
215
+ content: text,
216
+ created_at: new Date().toISOString()
217
+ })));
218
+ logger.info(`[pier-connector] 💬 Published agent reply to realtime chat for job ${jobId}`);
219
+ } catch (err) {
220
+ logger.error(`[pier-connector] Failed to send realtime reply: ${err.message}`);
221
+ }
222
+ } else if (msg) {
223
+ safeRespond(msg, responsePayload);
224
+
225
+ jobsCompleted++;
226
+ logger.info(
227
+ `[pier-connector] ✔ Job ${jobId} completed` +
228
+ (elapsed ? ` — latency: ${elapsed}ms` : ''),
229
+ );
230
+ }
190
231
 
191
- jobsCompleted++;
192
- logger.info(
193
- `[pier-connector] Job ${jobId} completed` +
194
- (elapsed ? ` — latency: ${elapsed}ms` : ''),
195
- );
232
+ // Delayed stop for job-specific message listener
233
+ // This allows for follow-up chat messages even after a job is "completed"
234
+ if (jobSubscriptions.has(jobId)) {
235
+ if (jobStopTimeouts.has(jobId)) {
236
+ clearTimeout(jobStopTimeouts.get(jobId));
237
+ }
238
+
239
+ const timeout = setTimeout(() => {
240
+ const sub = jobSubscriptions.get(jobId);
241
+ if (sub) {
242
+ logger.info(`[pier-connector] 🛑 Stopping listener for job ${jobId} (inactivity)`);
243
+ sub.stop();
244
+ jobSubscriptions.delete(jobId);
245
+ jobStopTimeouts.delete(jobId);
246
+ }
247
+ }, 60000); // 60s grace period
248
+
249
+ jobStopTimeouts.set(jobId, timeout);
250
+ }
196
251
  }
197
252
 
198
253
  return { ok: true };
@@ -290,6 +345,8 @@ export default function register(api) {
290
345
 
291
346
  // 4. Connect to NATS
292
347
  nc = await createNatsConnection(activeNatsUrl, logger);
348
+ js = jetstream(nc);
349
+ jsm = await jetstreamManager(nc);
293
350
  connectionStatus = 'connected';
294
351
  connectedAt = new Date();
295
352
 
@@ -297,18 +354,140 @@ export default function register(api) {
297
354
  const publicSubject = config.subject;
298
355
  const privateSubject = `jobs.node.${config.nodeId}`;
299
356
 
300
- logger.info(`[pier-connector] 👂 Listening to Marketplace: ${publicSubject}`);
301
- logger.info(`[pier-connector] 👂 Listening to Direct Messages: ${privateSubject}`);
357
+ // Public pool with load balancing via JetStream Durable Consumer
358
+ if (publicSubject) {
359
+ const durableName = `pier_marketplace_${config.queueGroup || 'default'}`;
360
+ const streamName = 'PIER_JOBS';
361
+ logger.info(`[pier-connector] 👂 Listening to Marketplace (JetStream): ${publicSubject} (Durable: ${durableName})`);
362
+
363
+ (async () => {
364
+ try {
365
+ let consumer = await js.consumers.get(streamName, durableName).catch(async () => {
366
+ logger.info(`[pier-connector] Creating new Marketplace Consumer: ${durableName}`);
367
+ await jsm.consumers.add(streamName, {
368
+ durable_name: durableName,
369
+ filter_subject: publicSubject,
370
+ deliver_policy: DeliverPolicy.All,
371
+ ack_policy: AckPolicy.Explicit,
372
+ deliver_group: config.queueGroup
373
+ });
374
+ return await js.consumers.get(streamName, durableName);
375
+ });
302
376
 
303
- // Public pool with load balancing
304
- nc.subscribe(publicSubject, {
305
- queue: config.queueGroup,
306
- callback: (err, msg) => handleMessage(msg)
307
- });
308
- // Private direct channel (no queue group for broadcast/direct)
309
- nc.subscribe(privateSubject, {
310
- callback: (err, msg) => handleMessage(msg)
311
- });
377
+ const iter = await consumer.consume();
378
+ for await (const msg of iter) {
379
+ await handleMessage(msg);
380
+ msg.ack();
381
+ }
382
+ } catch (err) {
383
+ logger.error(`[pier-connector] Marketplace JetStream error: ${err.message}`);
384
+ }
385
+ })();
386
+ } else {
387
+ logger.warn('[pier-connector] ⚠ No Marketplace subject defined. Skipping.');
388
+ }
389
+
390
+ // Private direct channel (JetStream Durable per Node)
391
+ if (privateSubject) {
392
+ const durableName = `pier_node_${config.nodeId.replace(/-/g, '_')}`;
393
+ const streamName = 'PIER_JOBS';
394
+ logger.info(`[pier-connector] 👂 Listening to Direct Messages (JetStream): ${privateSubject} (Durable: ${durableName})`);
395
+
396
+ (async () => {
397
+ try {
398
+ let consumer = await js.consumers.get(streamName, durableName).catch(async () => {
399
+ logger.info(`[pier-connector] Creating new Direct Consumer: ${durableName}`);
400
+ await jsm.consumers.add(streamName, {
401
+ durable_name: durableName,
402
+ filter_subject: privateSubject,
403
+ deliver_policy: DeliverPolicy.All,
404
+ ack_policy: AckPolicy.Explicit
405
+ });
406
+ return await js.consumers.get(streamName, durableName);
407
+ });
408
+
409
+ const iter = await consumer.consume();
410
+ for await (const msg of iter) {
411
+ await handleMessage(msg);
412
+ msg.ack();
413
+ }
414
+ } catch (err) {
415
+ logger.error(`[pier-connector] Direct JetStream error: ${err.message}`);
416
+ }
417
+ })();
418
+ }
419
+
420
+ // Reusable job message subscriber
421
+ async function subscribeToJobMessages(jobId) {
422
+ if (jobSubscriptions.has(jobId)) {
423
+ // Already subscribed, just reset timeout
424
+ if (jobStopTimeouts.has(jobId)) {
425
+ clearTimeout(jobStopTimeouts.get(jobId));
426
+ jobStopTimeouts.delete(jobId);
427
+ }
428
+ return;
429
+ }
430
+
431
+ const msgSubject = `jobs.job.${jobId}.msg`;
432
+ const streamName = 'PIER_JOBS';
433
+
434
+ try {
435
+ const cInfo = await jsm.consumers.add(streamName, {
436
+ filter_subject: msgSubject,
437
+ deliver_policy: DeliverPolicy.All,
438
+ ack_policy: AckPolicy.Explicit
439
+ });
440
+ const consumer = await js.consumers.get(streamName, cInfo.name);
441
+
442
+ const iter = await consumer.consume();
443
+ jobSubscriptions.set(jobId, iter);
444
+
445
+ for await (const msg of iter) {
446
+ msg.ack();
447
+ const rawMsg = new TextDecoder().decode(msg.data);
448
+ let msgPayload;
449
+ try {
450
+ msgPayload = JSON.parse(rawMsg);
451
+ } catch (e) { continue; }
452
+
453
+ // Ignore my own messages
454
+ if (msgPayload.sender_id === config.nodeId) continue;
455
+
456
+ if (msgPayload.type === 'receipt') continue;
457
+
458
+ // Send read receipt back
459
+ if (js && msgPayload.id) {
460
+ try {
461
+ const replySubject = `jobs.job.${jobId}.msg`;
462
+ await js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
463
+ type: 'receipt',
464
+ msg_id: msgPayload.id,
465
+ reader_id: config.nodeId
466
+ })));
467
+ logger.info(`[pier-connector] 👁️ Sent read receipt for message ${msgPayload.id}`);
468
+ } catch (err) {
469
+ logger.error(`[pier-connector] Failed to send read receipt: ${err.message}`);
470
+ }
471
+ }
472
+
473
+ const content = msgPayload.content;
474
+ logger.info(`[pier-connector] 💬 Message for job ${jobId}: "${truncate(content, 40)}"`);
475
+
476
+ await api.runtime.sendIncoming({
477
+ channelId: 'pier',
478
+ accountId: config.accountId || 'default',
479
+ senderId: `pier:${msgPayload.sender_id}`,
480
+ text: content,
481
+ metadata: {
482
+ pierJobId: jobId,
483
+ isRealtimeMsg: true
484
+ }
485
+ });
486
+ }
487
+ } catch (err) {
488
+ logger.error(`[pier-connector] Job JS message error for ${jobId}: ${err.message}`);
489
+ }
490
+ }
312
491
 
313
492
  // Unified Message Handler
314
493
  async function handleMessage(msg) {
@@ -323,6 +502,16 @@ export default function register(api) {
323
502
  return;
324
503
  }
325
504
 
505
+ // WAKEUP SIGNAL HANDLING
506
+ if (payload.type === 'wakeup') {
507
+ const { jobId } = payload;
508
+ if (jobId) {
509
+ logger.info(`[pier-connector] ⏰ Received wakeup signal for job ${jobId}`);
510
+ subscribeToJobMessages(jobId);
511
+ }
512
+ return;
513
+ }
514
+
326
515
  // V1.1 FEATURE: Task Poisoning / Poaching Protection
327
516
  if (payload.assigned_node_id && payload.assigned_node_id !== config.nodeId) {
328
517
  // Silent ignore - don't reply, don't log heavily
@@ -350,7 +539,7 @@ export default function register(api) {
350
539
  try {
351
540
  const inbound = {
352
541
  channelId: 'pier',
353
- accountId: 'default',
542
+ accountId: config.accountId || 'default',
354
543
  senderId: `pier:${job.meta?.sender ?? 'anonymous'}`,
355
544
  text: job.task,
356
545
  assignedAgentId: config.agentId || undefined,
@@ -366,6 +555,9 @@ export default function register(api) {
366
555
 
367
556
  if (api.runtime?.sendIncoming) {
368
557
  await api.runtime.sendIncoming(inbound);
558
+
559
+ // SUBSCRIBE to job-specific messages for real-time communication
560
+ subscribeToJobMessages(job.id);
369
561
  } else {
370
562
  throw new Error('Agent runtime not available');
371
563
  }
@@ -516,6 +708,62 @@ export default function register(api) {
516
708
  { optional: true },
517
709
  );
518
710
 
711
+ api.registerTool(
712
+ {
713
+ name: 'pier_chat',
714
+ description: 'Send a message to the employer regarding a specific job. Use this for clarification or progress updates.',
715
+ parameters: {
716
+ type: 'object',
717
+ properties: {
718
+ jobId: {
719
+ type: 'string',
720
+ description: 'The ID of the job to send the message for'
721
+ },
722
+ text: {
723
+ type: 'string',
724
+ description: 'The message content'
725
+ }
726
+ },
727
+ required: ['jobId', 'text']
728
+ },
729
+ async execute(_id, params) {
730
+ if (!nc || connectionStatus !== 'connected') {
731
+ return { content: [{ type: 'text', text: 'Error: NATS not connected' }] };
732
+ }
733
+
734
+ try {
735
+ const payload = {
736
+ sender_id: getActiveConfig().nodeId,
737
+ content: params.text,
738
+ created_at: new Date().toISOString()
739
+ };
740
+
741
+ const subject = `jobs.job.${params.jobId}.msg`;
742
+ await js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
743
+
744
+ // Also SAVE to database for persistence
745
+ const config = getActiveConfig();
746
+ await fetch(`${config.pierApiUrl}/jobs/${params.jobId}/messages`, {
747
+ method: 'POST',
748
+ headers: {
749
+ 'Content-Type': 'application/json',
750
+ 'X-API-Key': config.secretKey // Assuming secretKey can be used as API key for node-specific actions
751
+ },
752
+ body: JSON.stringify({
753
+ sender_id: config.nodeId,
754
+ content: params.text
755
+ })
756
+ });
757
+
758
+ return { content: [{ type: 'text', text: 'Message sent' }] };
759
+ } catch (err) {
760
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
761
+ }
762
+ }
763
+ },
764
+ { optional: true }
765
+ );
766
+
519
767
  // ── 4. Register /pier status command ───────────────────────────────
520
768
 
521
769
  api.registerCommand({
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { wsconnect } from '@nats-io/transport-node';
9
+ import { jetstream } from '@nats-io/jetstream';
9
10
 
10
11
  /**
11
12
  * Create and return a NATS connection over WebSocket.