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