@gholl-studio/pier-connector 0.0.7 → 0.0.9

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.0.9",
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,6 +46,10 @@ 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
 
@@ -59,7 +70,7 @@ export default function register(api) {
59
70
  secretKey: mergedCfg.secretKey || DEFAULTS.SECRET_KEY,
60
71
  privateKey: mergedCfg.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
61
72
  natsUrl: mergedCfg.natsUrl || DEFAULTS.NATS_URL,
62
- subject: mergedCfg.subject || DEFAULTS.PIER_SUBJECT,
73
+ subject: mergedCfg.subject || DEFAULTS.SUBJECT,
63
74
  publishSubject: mergedCfg.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
64
75
  queueGroup: mergedCfg.queueGroup || DEFAULTS.QUEUE_GROUP,
65
76
  agentId: mergedCfg.agentId || DEFAULTS.AGENT_ID,
@@ -186,13 +197,48 @@ export default function register(api) {
186
197
  walletAddress: getActiveConfig().walletAddress,
187
198
  });
188
199
 
189
- safeRespond(msg, responsePayload);
200
+ if (isRealtimeMsg && js) {
201
+ try {
202
+ const config = getActiveConfig();
203
+ const replySubject = `jobs.job.${jobId}.msg`;
204
+ await js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
205
+ sender_id: config.nodeId,
206
+ content: text,
207
+ created_at: new Date().toISOString()
208
+ })));
209
+ logger.info(`[pier-connector] 💬 Published agent reply to realtime chat for job ${jobId}`);
210
+ } catch (err) {
211
+ logger.error(`[pier-connector] Failed to send realtime reply: ${err.message}`);
212
+ }
213
+ } else {
214
+ safeRespond(msg, responsePayload);
190
215
 
191
- jobsCompleted++;
192
- logger.info(
193
- `[pier-connector] ✔ Job ${jobId} completed` +
194
- (elapsed ? ` — latency: ${elapsed}ms` : ''),
195
- );
216
+ jobsCompleted++;
217
+ logger.info(
218
+ `[pier-connector] ✔ Job ${jobId} completed` +
219
+ (elapsed ? ` — latency: ${elapsed}ms` : ''),
220
+ );
221
+ }
222
+
223
+ // Delayed stop for job-specific message listener
224
+ // This allows for follow-up chat messages even after a job is "completed"
225
+ if (jobSubscriptions.has(jobId)) {
226
+ if (jobStopTimeouts.has(jobId)) {
227
+ clearTimeout(jobStopTimeouts.get(jobId));
228
+ }
229
+
230
+ const timeout = setTimeout(() => {
231
+ const sub = jobSubscriptions.get(jobId);
232
+ if (sub) {
233
+ logger.info(`[pier-connector] 🛑 Stopping listener for job ${jobId} (inactivity)`);
234
+ sub.stop();
235
+ jobSubscriptions.delete(jobId);
236
+ jobStopTimeouts.delete(jobId);
237
+ }
238
+ }, 60000); // 60s grace period
239
+
240
+ jobStopTimeouts.set(jobId, timeout);
241
+ }
196
242
  }
197
243
 
198
244
  return { ok: true };
@@ -290,6 +336,8 @@ export default function register(api) {
290
336
 
291
337
  // 4. Connect to NATS
292
338
  nc = await createNatsConnection(activeNatsUrl, logger);
339
+ js = jetstream(nc);
340
+ jsm = await jetstreamManager(nc);
293
341
  connectionStatus = 'connected';
294
342
  connectedAt = new Date();
295
343
 
@@ -297,18 +345,140 @@ export default function register(api) {
297
345
  const publicSubject = config.subject;
298
346
  const privateSubject = `jobs.node.${config.nodeId}`;
299
347
 
300
- logger.info(`[pier-connector] 👂 Listening to Marketplace: ${publicSubject}`);
301
- logger.info(`[pier-connector] 👂 Listening to Direct Messages: ${privateSubject}`);
348
+ // Public pool with load balancing via JetStream Durable Consumer
349
+ if (publicSubject) {
350
+ const durableName = `pier_marketplace_${config.queueGroup || 'default'}`;
351
+ const streamName = 'PIER_JOBS';
352
+ logger.info(`[pier-connector] 👂 Listening to Marketplace (JetStream): ${publicSubject} (Durable: ${durableName})`);
353
+
354
+ (async () => {
355
+ try {
356
+ let consumer = await js.consumers.get(streamName, durableName).catch(async () => {
357
+ logger.info(`[pier-connector] Creating new Marketplace Consumer: ${durableName}`);
358
+ await jsm.consumers.add(streamName, {
359
+ durable_name: durableName,
360
+ filter_subject: publicSubject,
361
+ deliver_policy: DeliverPolicy.All,
362
+ ack_policy: AckPolicy.Explicit,
363
+ deliver_group: config.queueGroup
364
+ });
365
+ return await js.consumers.get(streamName, durableName);
366
+ });
302
367
 
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
- });
368
+ const iter = await consumer.consume();
369
+ for await (const msg of iter) {
370
+ await handleMessage(msg);
371
+ msg.ack();
372
+ }
373
+ } catch (err) {
374
+ logger.error(`[pier-connector] Marketplace JetStream error: ${err.message}`);
375
+ }
376
+ })();
377
+ } else {
378
+ logger.warn('[pier-connector] ⚠ No Marketplace subject defined. Skipping.');
379
+ }
380
+
381
+ // Private direct channel (JetStream Durable per Node)
382
+ if (privateSubject) {
383
+ const durableName = `pier_node_${config.nodeId.replace(/-/g, '_')}`;
384
+ const streamName = 'PIER_JOBS';
385
+ logger.info(`[pier-connector] 👂 Listening to Direct Messages (JetStream): ${privateSubject} (Durable: ${durableName})`);
386
+
387
+ (async () => {
388
+ try {
389
+ let consumer = await js.consumers.get(streamName, durableName).catch(async () => {
390
+ logger.info(`[pier-connector] Creating new Direct Consumer: ${durableName}`);
391
+ await jsm.consumers.add(streamName, {
392
+ durable_name: durableName,
393
+ filter_subject: privateSubject,
394
+ deliver_policy: DeliverPolicy.All,
395
+ ack_policy: AckPolicy.Explicit
396
+ });
397
+ return await js.consumers.get(streamName, durableName);
398
+ });
399
+
400
+ const iter = await consumer.consume();
401
+ for await (const msg of iter) {
402
+ await handleMessage(msg);
403
+ msg.ack();
404
+ }
405
+ } catch (err) {
406
+ logger.error(`[pier-connector] Direct JetStream error: ${err.message}`);
407
+ }
408
+ })();
409
+ }
410
+
411
+ // Reusable job message subscriber
412
+ async function subscribeToJobMessages(jobId) {
413
+ if (jobSubscriptions.has(jobId)) {
414
+ // Already subscribed, just reset timeout
415
+ if (jobStopTimeouts.has(jobId)) {
416
+ clearTimeout(jobStopTimeouts.get(jobId));
417
+ jobStopTimeouts.delete(jobId);
418
+ }
419
+ return;
420
+ }
421
+
422
+ const msgSubject = `jobs.job.${jobId}.msg`;
423
+ const streamName = 'PIER_JOBS';
424
+
425
+ try {
426
+ const cInfo = await jsm.consumers.add(streamName, {
427
+ filter_subject: msgSubject,
428
+ deliver_policy: DeliverPolicy.All,
429
+ ack_policy: AckPolicy.Explicit
430
+ });
431
+ const consumer = await js.consumers.get(streamName, cInfo.name);
432
+
433
+ const iter = await consumer.consume();
434
+ jobSubscriptions.set(jobId, iter);
435
+
436
+ for await (const msg of iter) {
437
+ msg.ack();
438
+ const rawMsg = new TextDecoder().decode(msg.data);
439
+ let msgPayload;
440
+ try {
441
+ msgPayload = JSON.parse(rawMsg);
442
+ } catch (e) { continue; }
443
+
444
+ // Ignore my own messages
445
+ if (msgPayload.sender_id === config.nodeId) continue;
446
+
447
+ if (msgPayload.type === 'receipt') continue;
448
+
449
+ // Send read receipt back
450
+ if (js && msgPayload.id) {
451
+ try {
452
+ const replySubject = `jobs.job.${jobId}.msg`;
453
+ await js.publish(replySubject, new TextEncoder().encode(JSON.stringify({
454
+ type: 'receipt',
455
+ msg_id: msgPayload.id,
456
+ reader_id: config.nodeId
457
+ })));
458
+ logger.info(`[pier-connector] 👁️ Sent read receipt for message ${msgPayload.id}`);
459
+ } catch (err) {
460
+ logger.error(`[pier-connector] Failed to send read receipt: ${err.message}`);
461
+ }
462
+ }
463
+
464
+ const content = msgPayload.content;
465
+ logger.info(`[pier-connector] 💬 Message for job ${jobId}: "${truncate(content, 40)}"`);
466
+
467
+ await api.runtime.sendIncoming({
468
+ channelId: 'pier',
469
+ accountId: 'default',
470
+ senderId: `pier:${msgPayload.sender_id}`,
471
+ text: content,
472
+ metadata: {
473
+ pierJobId: jobId,
474
+ isRealtimeMsg: true
475
+ }
476
+ });
477
+ }
478
+ } catch (err) {
479
+ logger.error(`[pier-connector] Job JS message error for ${jobId}: ${err.message}`);
480
+ }
481
+ }
312
482
 
313
483
  // Unified Message Handler
314
484
  async function handleMessage(msg) {
@@ -323,6 +493,16 @@ export default function register(api) {
323
493
  return;
324
494
  }
325
495
 
496
+ // WAKEUP SIGNAL HANDLING
497
+ if (payload.type === 'wakeup') {
498
+ const { jobId } = payload;
499
+ if (jobId) {
500
+ logger.info(`[pier-connector] ⏰ Received wakeup signal for job ${jobId}`);
501
+ subscribeToJobMessages(jobId);
502
+ }
503
+ return;
504
+ }
505
+
326
506
  // V1.1 FEATURE: Task Poisoning / Poaching Protection
327
507
  if (payload.assigned_node_id && payload.assigned_node_id !== config.nodeId) {
328
508
  // Silent ignore - don't reply, don't log heavily
@@ -366,6 +546,9 @@ export default function register(api) {
366
546
 
367
547
  if (api.runtime?.sendIncoming) {
368
548
  await api.runtime.sendIncoming(inbound);
549
+
550
+ // SUBSCRIBE to job-specific messages for real-time communication
551
+ subscribeToJobMessages(job.id);
369
552
  } else {
370
553
  throw new Error('Agent runtime not available');
371
554
  }
@@ -516,6 +699,62 @@ export default function register(api) {
516
699
  { optional: true },
517
700
  );
518
701
 
702
+ api.registerTool(
703
+ {
704
+ name: 'pier_chat',
705
+ description: 'Send a message to the employer regarding a specific job. Use this for clarification or progress updates.',
706
+ parameters: {
707
+ type: 'object',
708
+ properties: {
709
+ jobId: {
710
+ type: 'string',
711
+ description: 'The ID of the job to send the message for'
712
+ },
713
+ text: {
714
+ type: 'string',
715
+ description: 'The message content'
716
+ }
717
+ },
718
+ required: ['jobId', 'text']
719
+ },
720
+ async execute(_id, params) {
721
+ if (!nc || connectionStatus !== 'connected') {
722
+ return { content: [{ type: 'text', text: 'Error: NATS not connected' }] };
723
+ }
724
+
725
+ try {
726
+ const payload = {
727
+ sender_id: getActiveConfig().nodeId,
728
+ content: params.text,
729
+ created_at: new Date().toISOString()
730
+ };
731
+
732
+ const subject = `jobs.job.${params.jobId}.msg`;
733
+ await js.publish(subject, new TextEncoder().encode(JSON.stringify(payload)));
734
+
735
+ // Also SAVE to database for persistence
736
+ const config = getActiveConfig();
737
+ await fetch(`${config.pierApiUrl}/jobs/${params.jobId}/messages`, {
738
+ method: 'POST',
739
+ headers: {
740
+ 'Content-Type': 'application/json',
741
+ 'X-API-Key': config.secretKey // Assuming secretKey can be used as API key for node-specific actions
742
+ },
743
+ body: JSON.stringify({
744
+ sender_id: config.nodeId,
745
+ content: params.text
746
+ })
747
+ });
748
+
749
+ return { content: [{ type: 'text', text: 'Message sent' }] };
750
+ } catch (err) {
751
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
752
+ }
753
+ }
754
+ },
755
+ { optional: true }
756
+ );
757
+
519
758
  // ── 4. Register /pier status command ───────────────────────────────
520
759
 
521
760
  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.