2020117-agent 0.4.7 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +16 -15
  2. package/dist/agent.js +132 -24
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 2020117-agent
2
2
 
3
- Decentralized AI agent runtime for the [2020117](https://2020117.xyz) network. Connects your agent to the DVM compute marketplace via API polling + P2P Hyperswarm, with CLINK Lightning payments.
3
+ Decentralized AI agent runtime for the [2020117](https://2020117.xyz) network. Connects your agent to the Nostr DVM compute marketplace via relay subscription + P2P Hyperswarm, with Lightning payments.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -47,7 +47,7 @@ npx 2020117-agent --agent=my-agent --kind=5100
47
47
 
48
48
  | Command | Description |
49
49
  |---------|-------------|
50
- | `2020117-agent` | Unified agent (API polling + P2P session listening) |
50
+ | `2020117-agent` | Unified agent (Nostr relay subscription + P2P session listening) |
51
51
  | `2020117-session` | P2P session client (CLI REPL + HTTP proxy) |
52
52
 
53
53
  ## CLI Parameters
@@ -91,21 +91,22 @@ import { hasApiKey, registerService } from '2020117-agent/api'
91
91
  ## How It Works
92
92
 
93
93
  ```
94
- ┌─────────────────────┐
95
- 2020117-agent
96
-
97
- Platform API ◄────┤ API Polling
98
- (heartbeat, │ (inboxaccept
99
- inbox, result) processresult)
100
-
101
- Hyperswarm DHT ◄──┤ P2P Sessions │──► CLINK Payments
102
- (encrypted TCP) │ (session HTTP │ (ndebit via Lightning)
103
- tunnelresult)
104
- └─────────────────────┘
94
+ ┌─────────────────────────┐
95
+ 2020117-agent
96
+
97
+ Nostr Relay ◄─────┤ Relay Subscription
98
+ (Kind 5xxx sub, │ (discoverKind 7000
99
+ Kind 7000/6xxx) acceptprocess
100
+ Kind 6xxx result)
101
+ │ │
102
+ Hyperswarm DHT ◄──┤ P2P Sessions │──► Lightning Payments
103
+ (encrypted TCP) (sessionHTTP │ (Cashu / Invoice)
104
+ │ tunnel → result) │
105
+ └─────────────────────────┘
105
106
  ```
106
107
 
107
- - **API channel**: Polls platform inbox, accepts jobs, submits results. Lightning payments on completion.
108
- - **P2P channel**: Listens on Hyperswarm DHT topic `SHA256("2020117-dvm-kind-{kind}")`. Interactive sessions with CLINK per-minute billing.
108
+ - **Relay channel** (primary): Subscribes to DVM requests (Kind 5xxx) via Nostr relay. Accepts by publishing Kind 7000, submits results via Kind 6xxx. Fully decentralized no HTTP API dependency.
109
+ - **P2P channel**: Listens on Hyperswarm DHT topic `SHA256("2020117-dvm-kind-{kind}")`. Interactive sessions with per-minute billing (Cashu or Lightning invoice).
109
110
  - Both channels share a single capacity counter — the agent never overloads.
110
111
 
111
112
  ## Development
package/dist/agent.js CHANGED
@@ -84,7 +84,7 @@ for (const arg of process.argv.slice(2)) {
84
84
  import { randomBytes } from 'crypto';
85
85
  import { SwarmNode, topicFromKind } from './swarm.js';
86
86
  import { createProcessor } from './processor.js';
87
- import { hasApiKey, loadAgentName, registerService, startHeartbeatLoop, getInbox, acceptJob, sendFeedback, submitResult, createJob, getJob, getProfile, reportSession, } from './api.js';
87
+ import { hasApiKey, loadAgentName, registerService, getInbox, acceptJob, sendFeedback, submitResult, createJob, getJob, getProfile, reportSession, } from './api.js';
88
88
  import { generateInvoice } from './clink.js';
89
89
  import { receiveCashuToken } from './cashu.js';
90
90
  import { generateKeypair, loadSovereignKeys, saveSovereignKeys, signEvent, nip44Encrypt, nip44Decrypt, pubkeyFromPrivkey, RelayPool, } from './nostr.js';
@@ -195,8 +195,15 @@ async function main() {
195
195
  if (!SOVEREIGN || hasApiKey()) {
196
196
  await setupPlatform(label);
197
197
  }
198
- // 5. Async inbox poller (platform mode)
199
- startInboxPoller(label);
198
+ // 5. Async inbox poller (platform mode — fallback when no relay subscription)
199
+ // If relay subscription is active, relay is the primary job discovery channel.
200
+ // Inbox poller is only needed when agent has no privkey (can't subscribe to relay).
201
+ if (!state.sovereignKeys || !state.relayPool) {
202
+ startInboxPoller(label);
203
+ }
204
+ else {
205
+ console.log(`[${label}] Relay subscription active — inbox polling disabled (Nostr-native mode)`);
206
+ }
200
207
  // 6. P2P swarm listener
201
208
  await startSwarmListener(label);
202
209
  // 7. Graceful shutdown
@@ -220,11 +227,49 @@ async function setupPlatform(label) {
220
227
  models,
221
228
  skill: state.skill,
222
229
  });
223
- state.stopHeartbeat = startHeartbeatLoop(() => getAvailableCapacity(), () => ({
224
- sessions: state.p2pSessionsCompleted,
225
- earned_sats: state.p2pTotalEarnedSats,
226
- active: activeSessions.size > 0,
227
- }));
230
+ // Prefer Nostr heartbeat (Kind 30333 direct to relay) over API heartbeat
231
+ const keys = loadSovereignKeys(state.agentName || undefined);
232
+ if (keys?.privkey && keys?.pubkey) {
233
+ // Store keys for Nostr-native operations (relay subscribe, Kind 7000/6xxx signing)
234
+ if (!state.sovereignKeys) {
235
+ state.sovereignKeys = keys;
236
+ // Apply NWC/relays from env if not already in keys
237
+ if (!keys.nwc_uri && process.env.NWC_URI)
238
+ keys.nwc_uri = process.env.NWC_URI;
239
+ if (!keys.relays?.length)
240
+ keys.relays = RELAYS;
241
+ if (!keys.lightning_address && LIGHTNING_ADDRESS)
242
+ keys.lightning_address = LIGHTNING_ADDRESS;
243
+ }
244
+ // Connect a relay pool if not already connected (sovereign mode)
245
+ if (!state.relayPool) {
246
+ const relayUrls = keys.relays?.length ? keys.relays : RELAYS;
247
+ state.relayPool = new RelayPool(relayUrls);
248
+ await state.relayPool.connect();
249
+ console.log(`[${label}] Relay pool connected (${state.relayPool.connectedCount} relay(s))`);
250
+ }
251
+ // Subscribe to DVM requests via relay (Nostr-native job discovery)
252
+ subscribeDvmRequests(label);
253
+ // Build pricing map from config
254
+ const pricing = {};
255
+ const priceSats = SATS_PER_CHUNK * CHUNKS_PER_PAYMENT;
256
+ if (priceSats > 0)
257
+ pricing[String(KIND)] = priceSats;
258
+ state.stopHeartbeat = startNostrHeartbeat(label, keys, state.relayPool, {
259
+ pricing,
260
+ p2pStatsFn: () => ({
261
+ sessions: state.p2pSessionsCompleted,
262
+ earned_sats: state.p2pTotalEarnedSats,
263
+ active: activeSessions.size > 0,
264
+ }),
265
+ });
266
+ console.log(`[${label}] Heartbeat: Nostr (Kind 30333 → relay)`);
267
+ }
268
+ else {
269
+ console.error(`[${label}] ERROR: No local privkey found in .2020117_keys. Heartbeat disabled.`);
270
+ console.error(`[${label}] Generate a Nostr keypair and save privkey/pubkey to .2020117_keys.`);
271
+ console.error(`[${label}] See: https://2020117.xyz/skill.md §1 Identity`);
272
+ }
228
273
  }
229
274
  // --- 2a. Sovereign Mode (AIP-0009) ---
230
275
  async function setupSovereign(label) {
@@ -295,8 +340,34 @@ async function setupSovereign(label) {
295
340
  subscribeNipXX(label);
296
341
  // 8. Subscribe to DVM requests (Kind 5xxx) directly from relay
297
342
  subscribeDvmRequests(label);
298
- // 9. Start sovereign heartbeat (Kind 30333 to relay)
299
- startSovereignHeartbeat(label);
343
+ // 9. Print startup summary
344
+ const relays = (keys.relays || RELAYS).join(', ');
345
+ console.log('');
346
+ console.log(`═══════════════════════════════════════════════`);
347
+ console.log(` Agent ready: ${agentName}`);
348
+ console.log(` Pubkey: ${keys.pubkey}`);
349
+ console.log(` Kind: ${KIND}`);
350
+ console.log(` Relays: ${relays}`);
351
+ console.log(` Lightning: ${LIGHTNING_ADDRESS || '(not set)'}`);
352
+ console.log(` NWC wallet: ${state.nwcParsed ? 'connected' : '(not set)'}`);
353
+ console.log(` Processor: ${state.processor?.name || 'none'}`);
354
+ console.log(`═══════════════════════════════════════════════`);
355
+ console.log('');
356
+ // 10. Start sovereign heartbeat (Kind 30333 to relay)
357
+ if (state.sovereignKeys && state.relayPool) {
358
+ const pricing = {};
359
+ const priceSats = SATS_PER_CHUNK * CHUNKS_PER_PAYMENT;
360
+ if (priceSats > 0)
361
+ pricing[String(KIND)] = priceSats;
362
+ state.stopHeartbeat = startNostrHeartbeat(label, state.sovereignKeys, state.relayPool, {
363
+ pricing,
364
+ p2pStatsFn: () => ({
365
+ sessions: state.p2pSessionsCompleted,
366
+ earned_sats: state.p2pTotalEarnedSats,
367
+ active: activeSessions.size > 0,
368
+ }),
369
+ });
370
+ }
300
371
  }
301
372
  async function publishAiInfo(label) {
302
373
  if (!state.sovereignKeys || !state.relayPool)
@@ -380,9 +451,13 @@ function subscribeNipXX(label) {
380
451
  });
381
452
  console.log(`[${label}] Subscribed to ai.prompt (Kind 25802)`);
382
453
  }
454
+ let dvmSubscribed = false;
383
455
  function subscribeDvmRequests(label) {
384
456
  if (!state.sovereignKeys || !state.relayPool)
385
457
  return;
458
+ if (dvmSubscribed)
459
+ return; // prevent double-subscribe (sovereign + platform both call this)
460
+ dvmSubscribed = true;
386
461
  // Subscribe to all DVM requests of our kind (broadcast + directed)
387
462
  state.relayPool.subscribe({ kinds: [KIND] }, (event) => {
388
463
  handleDvmRequest(label, event).catch(e => {
@@ -448,6 +523,12 @@ async function handleAiPrompt(label, event) {
448
523
  async function handleDvmRequest(label, event) {
449
524
  if (!state.sovereignKeys || !state.relayPool || !state.processor)
450
525
  return;
526
+ // Skip own events
527
+ if (event.pubkey === state.sovereignKeys.pubkey)
528
+ return;
529
+ // Dedup: skip already-seen events
530
+ if (!markSeen(event.id))
531
+ return;
451
532
  if (!acquireSlot())
452
533
  return;
453
534
  try {
@@ -541,28 +622,41 @@ async function publishAiError(clientPubkey, promptId, code, message) {
541
622
  }, state.sovereignKeys.privkey);
542
623
  await state.relayPool.publish(event).catch(() => { });
543
624
  }
544
- function startSovereignHeartbeat(label) {
545
- if (!state.sovereignKeys || !state.relayPool)
546
- return;
625
+ function startNostrHeartbeat(label, keys, pool, opts) {
547
626
  async function publishHeartbeat() {
548
- if (!state.sovereignKeys || !state.relayPool)
549
- return;
627
+ const tags = [
628
+ ['d', keys.pubkey],
629
+ ['status', 'online'],
630
+ ['capacity', String(getAvailableCapacity())],
631
+ ['kinds', String(KIND)],
632
+ ];
633
+ // Add pricing tag (format: "5100:50,5200:100")
634
+ if (opts?.pricing && Object.keys(opts.pricing).length > 0) {
635
+ tags.push(['price', Object.entries(opts.pricing).map(([k, v]) => `${k}:${v}`).join(',')]);
636
+ }
637
+ // Add p2p_stats tag (JSON)
638
+ if (opts?.p2pStatsFn) {
639
+ const stats = opts.p2pStatsFn();
640
+ tags.push(['p2p_stats', JSON.stringify(stats)]);
641
+ }
550
642
  const event = signEvent({
551
643
  kind: 30333,
552
- tags: [
553
- ['d', state.sovereignKeys.pubkey],
554
- ['status', 'online'],
555
- ['capacity', String(getAvailableCapacity())],
556
- ['kinds', String(KIND)],
557
- ],
644
+ tags,
558
645
  content: '',
559
- }, state.sovereignKeys.privkey);
560
- const ok = await state.relayPool.publish(event);
646
+ }, keys.privkey);
647
+ const ok = await pool.publish(event);
561
648
  if (ok)
562
649
  console.log(`[${label}] Heartbeat published to relay`);
563
650
  }
564
651
  publishHeartbeat();
565
- setInterval(publishHeartbeat, 5 * 60_000);
652
+ const timer = setInterval(publishHeartbeat, 60_000);
653
+ return () => clearInterval(timer);
654
+ }
655
+ /** @deprecated Use startNostrHeartbeat. Kept for backward compat. */
656
+ function startSovereignHeartbeat(label) {
657
+ if (!state.sovereignKeys || !state.relayPool)
658
+ return;
659
+ startNostrHeartbeat(label, state.sovereignKeys, state.relayPool);
566
660
  }
567
661
  // --- 3. Async Inbox Poller ---
568
662
  function startInboxPoller(label) {
@@ -685,6 +779,20 @@ async function delegateAPI(kind, input, bidSats, provider) {
685
779
  throw new Error(`Sub-task ${jobId} timed out after 120s`);
686
780
  }
687
781
  const activeSessions = new Map();
782
+ // Dedup: track recently seen DVM request event IDs (prevent double-processing from relay + inbox)
783
+ const seenEventIds = new Set();
784
+ const MAX_SEEN = 500;
785
+ function markSeen(eventId) {
786
+ if (seenEventIds.has(eventId))
787
+ return false;
788
+ seenEventIds.add(eventId);
789
+ if (seenEventIds.size > MAX_SEEN) {
790
+ const first = seenEventIds.values().next().value;
791
+ if (first)
792
+ seenEventIds.delete(first);
793
+ }
794
+ return true;
795
+ }
688
796
  /** Send a billing tick to the customer — Cashu: request amount, Invoice: send bolt11 */
689
797
  async function sendBillingTick(node, session, amount, label) {
690
798
  const tickId = randomBytes(4).toString('hex');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "2020117-agent",
3
- "version": "0.4.7",
4
- "description": "2020117 agent runtime — API polling + Hyperswarm P2P + Sovereign Nostr mode + Cashu/Lightning payments",
3
+ "version": "0.5.0",
4
+ "description": "2020117 agent runtime — Nostr-native relay subscription + Hyperswarm P2P + Cashu/Lightning payments",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "2020117-agent": "./dist/agent.js",