@voidly/agent-sdk 1.8.1 → 1.9.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/dist/index.mjs CHANGED
@@ -2337,11 +2337,16 @@ async function sha256(data) {
2337
2337
  }
2338
2338
  var VoidlyAgent = class _VoidlyAgent {
2339
2339
  constructor(identity, config) {
2340
+ this._pinnedDids = /* @__PURE__ */ new Set();
2341
+ this._listeners = /* @__PURE__ */ new Set();
2342
+ this._conversations = /* @__PURE__ */ new Map();
2340
2343
  this.did = identity.did;
2341
2344
  this.apiKey = identity.apiKey;
2342
2345
  this.signingKeyPair = identity.signingKeyPair;
2343
2346
  this.encryptionKeyPair = identity.encryptionKeyPair;
2344
2347
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2348
+ this.autoPin = config?.autoPin !== false;
2349
+ this.defaultRetries = config?.retries ?? 3;
2345
2350
  }
2346
2351
  // ─── Factory Methods ────────────────────────────────────────────────────────
2347
2352
  /**
@@ -2408,14 +2413,23 @@ var VoidlyAgent = class _VoidlyAgent {
2408
2413
  }
2409
2414
  // ─── Messaging ──────────────────────────────────────────────────────────────
2410
2415
  /**
2411
- * Send an E2E encrypted message. Encryption happens locally.
2412
- * The relay server NEVER sees the plaintext or private keys.
2416
+ * Send an E2E encrypted message with automatic retry and transparent TOFU.
2417
+ * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2418
+ *
2419
+ * Features:
2420
+ * - **Auto-retry** with exponential backoff on transient failures
2421
+ * - **Transparent TOFU** — automatically pins recipient keys on first contact
2422
+ * - **Key verification** — warns if pinned keys have changed (potential MitM)
2413
2423
  */
2414
2424
  async send(recipientDid, message, options = {}) {
2425
+ const maxRetries = options.retries ?? this.defaultRetries;
2415
2426
  const profile = await this.getIdentity(recipientDid);
2416
2427
  if (!profile) {
2417
2428
  throw new Error(`Recipient ${recipientDid} not found`);
2418
2429
  }
2430
+ if (this.autoPin && !options.skipPin) {
2431
+ await this._autoPinKeys(recipientDid);
2432
+ }
2419
2433
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2420
2434
  const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2421
2435
  const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
@@ -2431,31 +2445,36 @@ var VoidlyAgent = class _VoidlyAgent {
2431
2445
  ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext))
2432
2446
  });
2433
2447
  const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
2434
- const res = await fetch(`${this.baseUrl}/v1/agent/send/encrypted`, {
2435
- method: "POST",
2436
- headers: {
2437
- "Content-Type": "application/json",
2438
- "X-Agent-Key": this.apiKey
2448
+ const payload = {
2449
+ to: recipientDid,
2450
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
2451
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2452
+ signature: (0, import_tweetnacl_util.encodeBase64)(signature),
2453
+ envelope: envelopeData,
2454
+ content_type: options.contentType || "text/plain",
2455
+ message_type: options.messageType || "text",
2456
+ thread_id: options.threadId,
2457
+ reply_to: options.replyTo,
2458
+ ttl: options.ttl
2459
+ };
2460
+ const raw = await this._fetchWithRetry(
2461
+ `${this.baseUrl}/v1/agent/send/encrypted`,
2462
+ {
2463
+ method: "POST",
2464
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2465
+ body: JSON.stringify(payload)
2439
2466
  },
2440
- body: JSON.stringify({
2441
- to: recipientDid,
2442
- ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
2443
- nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2444
- signature: (0, import_tweetnacl_util.encodeBase64)(signature),
2445
- envelope: envelopeData,
2446
- // Pass signed envelope so receiver can verify
2447
- content_type: options.contentType || "text/plain",
2448
- message_type: options.messageType || "text",
2449
- thread_id: options.threadId,
2450
- reply_to: options.replyTo,
2451
- ttl: options.ttl
2452
- })
2453
- });
2454
- if (!res.ok) {
2455
- const err = await res.json().catch(() => ({}));
2456
- throw new Error(`Send failed: ${err.error?.message || err.error || res.statusText}`);
2457
- }
2458
- return await res.json();
2467
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2468
+ );
2469
+ return {
2470
+ id: raw.id,
2471
+ from: raw.from,
2472
+ to: raw.to,
2473
+ timestamp: raw.timestamp,
2474
+ expiresAt: raw.expires_at || raw.expiresAt,
2475
+ encrypted: raw.encrypted,
2476
+ clientSide: raw.client_side || raw.clientSide
2477
+ };
2459
2478
  }
2460
2479
  /**
2461
2480
  * Receive and decrypt messages. Decryption happens locally.
@@ -3520,6 +3539,413 @@ var VoidlyAgent = class _VoidlyAgent {
3520
3539
  if (!res.ok) throw new Error(`Key verify failed: ${res.status}`);
3521
3540
  return res.json();
3522
3541
  }
3542
+ // ═══════════════════════════════════════════════════════════════════════════
3543
+ // LISTEN — Event-Driven Message Receiving
3544
+ // ═══════════════════════════════════════════════════════════════════════════
3545
+ /**
3546
+ * Listen for incoming messages with an event-driven callback.
3547
+ * Uses adaptive polling — speeds up when messages are flowing, slows down when idle.
3548
+ * Automatically sends heartbeat pings to signal the agent is online.
3549
+ *
3550
+ * @example
3551
+ * ```ts
3552
+ * // Simple listener
3553
+ * const handle = agent.listen((msg) => {
3554
+ * console.log(`${msg.from}: ${msg.content}`);
3555
+ * });
3556
+ *
3557
+ * // Stop after 60 seconds
3558
+ * setTimeout(() => handle.stop(), 60000);
3559
+ *
3560
+ * // With options
3561
+ * const handle = agent.listen(
3562
+ * (msg) => console.log(msg.content),
3563
+ * {
3564
+ * interval: 1000, // poll every 1s
3565
+ * from: 'did:voidly:x', // only from this agent
3566
+ * threadId: 'conv-1', // only this thread
3567
+ * adaptive: true, // slow down when idle
3568
+ * heartbeat: true, // send pings
3569
+ * }
3570
+ * );
3571
+ * ```
3572
+ */
3573
+ listen(onMessage, options = {}, onError) {
3574
+ const interval = Math.max(options.interval || 2e3, 500);
3575
+ const adaptive = options.adaptive !== false;
3576
+ const autoMarkRead = options.autoMarkRead !== false;
3577
+ const unreadOnly = options.unreadOnly !== false;
3578
+ const heartbeat = options.heartbeat !== false;
3579
+ const heartbeatInterval = options.heartbeatInterval || 6e4;
3580
+ let active = true;
3581
+ let currentInterval = interval;
3582
+ let consecutiveEmpty = 0;
3583
+ let lastSeen;
3584
+ let timer = null;
3585
+ let heartbeatTimer = null;
3586
+ const handle = {
3587
+ stop: () => {
3588
+ active = false;
3589
+ if (timer) clearTimeout(timer);
3590
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
3591
+ this._listeners.delete(handle);
3592
+ },
3593
+ get active() {
3594
+ return active;
3595
+ }
3596
+ };
3597
+ this._listeners.add(handle);
3598
+ if (heartbeat) {
3599
+ heartbeatTimer = setInterval(async () => {
3600
+ if (!active) return;
3601
+ try {
3602
+ await this.ping();
3603
+ } catch {
3604
+ }
3605
+ }, heartbeatInterval);
3606
+ this.ping().catch(() => {
3607
+ });
3608
+ }
3609
+ const poll = async () => {
3610
+ if (!active || options.signal?.aborted) {
3611
+ handle.stop();
3612
+ return;
3613
+ }
3614
+ try {
3615
+ const messages = await this.receive({
3616
+ since: lastSeen,
3617
+ from: options.from,
3618
+ threadId: options.threadId,
3619
+ messageType: options.messageType,
3620
+ unreadOnly,
3621
+ limit: 50
3622
+ });
3623
+ if (messages.length > 0) {
3624
+ consecutiveEmpty = 0;
3625
+ if (adaptive) currentInterval = Math.max(interval / 2, 500);
3626
+ for (const msg of messages) {
3627
+ try {
3628
+ await onMessage(msg);
3629
+ if (autoMarkRead) {
3630
+ await this.markRead(msg.id).catch(() => {
3631
+ });
3632
+ }
3633
+ } catch (err) {
3634
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
3635
+ }
3636
+ }
3637
+ lastSeen = messages[messages.length - 1].timestamp;
3638
+ } else {
3639
+ consecutiveEmpty++;
3640
+ if (adaptive && consecutiveEmpty > 3) {
3641
+ currentInterval = Math.min(currentInterval * 1.5, interval * 4);
3642
+ }
3643
+ }
3644
+ } catch (err) {
3645
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
3646
+ currentInterval = Math.min(currentInterval * 2, interval * 8);
3647
+ }
3648
+ if (active && !options.signal?.aborted) {
3649
+ timer = setTimeout(poll, currentInterval);
3650
+ }
3651
+ };
3652
+ poll();
3653
+ return handle;
3654
+ }
3655
+ /**
3656
+ * Listen for messages as an async iterator.
3657
+ * Enables `for await` syntax for message processing.
3658
+ *
3659
+ * @example
3660
+ * ```ts
3661
+ * for await (const msg of agent.messages({ unreadOnly: true })) {
3662
+ * console.log(`${msg.from}: ${msg.content}`);
3663
+ * if (msg.content === 'quit') break;
3664
+ * }
3665
+ * ```
3666
+ */
3667
+ async *messages(options = {}) {
3668
+ const queue = [];
3669
+ let resolve = null;
3670
+ let done = false;
3671
+ const handle = this.listen(
3672
+ (msg) => {
3673
+ queue.push(msg);
3674
+ if (resolve) {
3675
+ resolve();
3676
+ resolve = null;
3677
+ }
3678
+ },
3679
+ { ...options, autoMarkRead: options.autoMarkRead !== false }
3680
+ );
3681
+ options.signal?.addEventListener("abort", () => {
3682
+ done = true;
3683
+ handle.stop();
3684
+ if (resolve) {
3685
+ resolve();
3686
+ resolve = null;
3687
+ }
3688
+ });
3689
+ try {
3690
+ while (!done && !options.signal?.aborted) {
3691
+ if (queue.length > 0) {
3692
+ yield queue.shift();
3693
+ } else {
3694
+ await new Promise((r) => {
3695
+ resolve = r;
3696
+ });
3697
+ }
3698
+ }
3699
+ } finally {
3700
+ handle.stop();
3701
+ }
3702
+ }
3703
+ /**
3704
+ * Stop all active listeners. Useful for clean shutdown.
3705
+ */
3706
+ stopAll() {
3707
+ for (const listener of this._listeners) {
3708
+ listener.stop();
3709
+ }
3710
+ this._listeners.clear();
3711
+ }
3712
+ // ═══════════════════════════════════════════════════════════════════════════
3713
+ // CONVERSATIONS — Thread Management
3714
+ // ═══════════════════════════════════════════════════════════════════════════
3715
+ /**
3716
+ * Start or resume a conversation with another agent.
3717
+ * Automatically manages thread IDs, message history, and reply chains.
3718
+ *
3719
+ * @example
3720
+ * ```ts
3721
+ * const conv = agent.conversation(otherDid);
3722
+ * await conv.say('Hello!');
3723
+ * await conv.say('How are you?');
3724
+ *
3725
+ * // Get full history
3726
+ * const history = await conv.history();
3727
+ *
3728
+ * // Listen for replies in this conversation
3729
+ * conv.onReply((msg) => {
3730
+ * console.log(`Reply: ${msg.content}`);
3731
+ * });
3732
+ * ```
3733
+ */
3734
+ conversation(peerDid, threadId) {
3735
+ const tid = threadId || `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3736
+ const key = `${peerDid}:${tid}`;
3737
+ if (this._conversations.has(key)) {
3738
+ return this._conversations.get(key);
3739
+ }
3740
+ const conv = new Conversation(this, peerDid, tid);
3741
+ this._conversations.set(key, conv);
3742
+ return conv;
3743
+ }
3744
+ // ═══════════════════════════════════════════════════════════════════════════
3745
+ // INTERNAL — Retry, Auto-Pin
3746
+ // ═══════════════════════════════════════════════════════════════════════════
3747
+ /** @internal Auto-pin keys on first contact (TOFU) */
3748
+ async _autoPinKeys(did) {
3749
+ if (this._pinnedDids.has(did)) return;
3750
+ this._pinnedDids.add(did);
3751
+ try {
3752
+ const result = await this.pinKeys(did);
3753
+ if (result.key_changed && result.warning) {
3754
+ console.warn(`[voidly] \u26A0 Key change detected for ${did}: ${result.warning}`);
3755
+ }
3756
+ } catch {
3757
+ }
3758
+ }
3759
+ /** @internal Fetch with exponential backoff retry */
3760
+ async _fetchWithRetry(url, init, retry = {}) {
3761
+ const maxRetries = retry.maxRetries ?? 3;
3762
+ const baseDelay = retry.baseDelay ?? 500;
3763
+ const maxDelay = retry.maxDelay ?? 1e4;
3764
+ let lastError = null;
3765
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3766
+ try {
3767
+ const res = await fetch(url, init);
3768
+ if (res.ok) {
3769
+ return await res.json();
3770
+ }
3771
+ const err = await res.json().catch(() => ({}));
3772
+ const errMsg = err.error?.message || err.error || res.statusText;
3773
+ if (res.status >= 400 && res.status < 500) {
3774
+ throw new Error(`Send failed (${res.status}): ${errMsg}`);
3775
+ }
3776
+ lastError = new Error(`Send failed (${res.status}): ${errMsg}`);
3777
+ } catch (err) {
3778
+ if (err instanceof Error && err.message.startsWith("Send failed (4")) {
3779
+ throw err;
3780
+ }
3781
+ lastError = err instanceof Error ? err : new Error(String(err));
3782
+ }
3783
+ if (attempt < maxRetries) {
3784
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
3785
+ const jitter = delay * (0.5 + Math.random() * 0.5);
3786
+ await new Promise((r) => setTimeout(r, jitter));
3787
+ }
3788
+ }
3789
+ throw lastError || new Error("Send failed after retries");
3790
+ }
3791
+ };
3792
+ var Conversation = class {
3793
+ /** @internal */
3794
+ constructor(agent, peerDid, threadId) {
3795
+ this._lastMessageId = null;
3796
+ this._messageHistory = [];
3797
+ this._listener = null;
3798
+ this._replyHandlers = [];
3799
+ this.agent = agent;
3800
+ this.peerDid = peerDid;
3801
+ this.threadId = threadId;
3802
+ }
3803
+ /**
3804
+ * Send a message in this conversation. Auto-threaded and auto-linked to previous message.
3805
+ */
3806
+ async say(content, options) {
3807
+ const result = await this.agent.send(this.peerDid, content, {
3808
+ ...options,
3809
+ threadId: this.threadId,
3810
+ replyTo: this._lastMessageId || void 0
3811
+ });
3812
+ this._lastMessageId = result.id;
3813
+ this._messageHistory.push({
3814
+ id: result.id,
3815
+ from: this.agent.did,
3816
+ content,
3817
+ timestamp: result.timestamp,
3818
+ signatureValid: true,
3819
+ messageType: options?.messageType || "text"
3820
+ });
3821
+ return result;
3822
+ }
3823
+ /**
3824
+ * Get conversation history (both sent and received messages in this thread).
3825
+ */
3826
+ async history(options) {
3827
+ const received = await this.agent.receive({
3828
+ threadId: this.threadId,
3829
+ from: this.peerDid,
3830
+ limit: options?.limit || 100
3831
+ });
3832
+ const all = [
3833
+ ...this._messageHistory,
3834
+ ...received.map((m) => ({
3835
+ id: m.id,
3836
+ from: m.from,
3837
+ content: m.content,
3838
+ timestamp: m.timestamp,
3839
+ signatureValid: m.signatureValid,
3840
+ messageType: m.messageType
3841
+ }))
3842
+ ];
3843
+ const seen = /* @__PURE__ */ new Set();
3844
+ return all.filter((m) => {
3845
+ if (seen.has(m.id)) return false;
3846
+ seen.add(m.id);
3847
+ return true;
3848
+ }).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
3849
+ }
3850
+ /**
3851
+ * Register a callback for replies in this conversation.
3852
+ */
3853
+ onReply(handler) {
3854
+ this._replyHandlers.push(handler);
3855
+ if (!this._listener) {
3856
+ this._listener = this.agent.listen(
3857
+ async (msg) => {
3858
+ this._messageHistory.push({
3859
+ id: msg.id,
3860
+ from: msg.from,
3861
+ content: msg.content,
3862
+ timestamp: msg.timestamp,
3863
+ signatureValid: msg.signatureValid,
3864
+ messageType: msg.messageType
3865
+ });
3866
+ this._lastMessageId = msg.id;
3867
+ for (const h of this._replyHandlers) {
3868
+ try {
3869
+ await h(msg);
3870
+ } catch {
3871
+ }
3872
+ }
3873
+ },
3874
+ { from: this.peerDid, threadId: this.threadId, autoMarkRead: true }
3875
+ );
3876
+ }
3877
+ }
3878
+ /**
3879
+ * Wait for the next reply in this conversation (Promise-based).
3880
+ *
3881
+ * @param timeoutMs - Maximum time to wait (default: 30000ms)
3882
+ * @throws Error on timeout
3883
+ */
3884
+ async waitForReply(timeoutMs = 3e4) {
3885
+ return new Promise((resolve, reject) => {
3886
+ let resolved = false;
3887
+ const timeout = setTimeout(() => {
3888
+ if (!resolved) {
3889
+ resolved = true;
3890
+ reject(new Error(`No reply received within ${timeoutMs}ms`));
3891
+ }
3892
+ }, timeoutMs);
3893
+ const check = async () => {
3894
+ while (!resolved) {
3895
+ const messages = await this.agent.receive({
3896
+ from: this.peerDid,
3897
+ threadId: this.threadId,
3898
+ unreadOnly: true,
3899
+ limit: 1
3900
+ });
3901
+ if (messages.length > 0 && !resolved) {
3902
+ resolved = true;
3903
+ clearTimeout(timeout);
3904
+ const msg = messages[0];
3905
+ this._messageHistory.push({
3906
+ id: msg.id,
3907
+ from: msg.from,
3908
+ content: msg.content,
3909
+ timestamp: msg.timestamp,
3910
+ signatureValid: msg.signatureValid,
3911
+ messageType: msg.messageType
3912
+ });
3913
+ this._lastMessageId = msg.id;
3914
+ await this.agent.markRead(msg.id).catch(() => {
3915
+ });
3916
+ resolve(msg);
3917
+ return;
3918
+ }
3919
+ await new Promise((r) => setTimeout(r, 1500));
3920
+ }
3921
+ };
3922
+ check().catch((err) => {
3923
+ if (!resolved) {
3924
+ resolved = true;
3925
+ clearTimeout(timeout);
3926
+ reject(err);
3927
+ }
3928
+ });
3929
+ });
3930
+ }
3931
+ /**
3932
+ * Stop listening for replies and clean up.
3933
+ */
3934
+ close() {
3935
+ if (this._listener) {
3936
+ this._listener.stop();
3937
+ this._listener = null;
3938
+ }
3939
+ this._replyHandlers = [];
3940
+ }
3941
+ /** Number of messages tracked locally */
3942
+ get length() {
3943
+ return this._messageHistory.length;
3944
+ }
3945
+ /** The last message in this conversation */
3946
+ get lastMessage() {
3947
+ return this._messageHistory.length > 0 ? this._messageHistory[this._messageHistory.length - 1] : null;
3948
+ }
3523
3949
  };
3524
3950
  var export_decodeBase64 = import_tweetnacl_util.decodeBase64;
3525
3951
  var export_decodeUTF8 = import_tweetnacl_util.decodeUTF8;
@@ -3527,6 +3953,7 @@ var export_encodeBase64 = import_tweetnacl_util.encodeBase64;
3527
3953
  var export_encodeUTF8 = import_tweetnacl_util.encodeUTF8;
3528
3954
  var export_nacl = import_tweetnacl.default;
3529
3955
  export {
3956
+ Conversation,
3530
3957
  VoidlyAgent,
3531
3958
  export_decodeBase64 as decodeBase64,
3532
3959
  export_decodeUTF8 as decodeUTF8,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "E2E encrypted agent-to-agent communication SDK — true client-side encryption",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",