@voidly/agent-sdk 1.8.2 → 2.0.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,
@@ -2345,13 +2346,82 @@ async function sha256(data) {
2345
2346
  const { createHash } = await import("crypto");
2346
2347
  return createHash("sha256").update(data).digest("hex");
2347
2348
  }
2349
+ function padMessage(content) {
2350
+ const contentLen = content.length;
2351
+ const totalLen = Math.max(256, nextPowerOf2(contentLen + 2));
2352
+ const padded = new Uint8Array(totalLen);
2353
+ padded[0] = contentLen >> 8 & 255;
2354
+ padded[1] = contentLen & 255;
2355
+ padded.set(content, 2);
2356
+ const randomPad = import_tweetnacl.default.randomBytes(totalLen - contentLen - 2);
2357
+ padded.set(randomPad, contentLen + 2);
2358
+ return padded;
2359
+ }
2360
+ function unpadMessage(padded) {
2361
+ if (padded.length < 2) return padded;
2362
+ const contentLen = padded[0] << 8 | padded[1];
2363
+ if (contentLen + 2 > padded.length) return padded;
2364
+ return padded.slice(2, 2 + contentLen);
2365
+ }
2366
+ function nextPowerOf2(n) {
2367
+ let p = 1;
2368
+ while (p < n) p <<= 1;
2369
+ return p;
2370
+ }
2371
+ async function ratchetStep(chainKey) {
2372
+ const encoder = new TextEncoder();
2373
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
2374
+ const ckInput = new Uint8Array([...chainKey, 1]);
2375
+ const mkInput = new Uint8Array([...chainKey, 2]);
2376
+ const nextChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", ckInput));
2377
+ const messageKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", mkInput));
2378
+ return { nextChainKey: nextChainKey2, messageKey: messageKey2 };
2379
+ }
2380
+ const { createHash } = await import("crypto");
2381
+ const nextChainKey = new Uint8Array(
2382
+ createHash("sha256").update(Buffer.from([...chainKey, 1])).digest()
2383
+ );
2384
+ const messageKey = new Uint8Array(
2385
+ createHash("sha256").update(Buffer.from([...chainKey, 2])).digest()
2386
+ );
2387
+ return { nextChainKey, messageKey };
2388
+ }
2389
+ function sealEnvelope(senderDid, plaintext) {
2390
+ return JSON.stringify({
2391
+ v: 2,
2392
+ from: senderDid,
2393
+ msg: plaintext,
2394
+ ts: (/* @__PURE__ */ new Date()).toISOString()
2395
+ });
2396
+ }
2397
+ function unsealEnvelope(plaintext) {
2398
+ try {
2399
+ const parsed = JSON.parse(plaintext);
2400
+ if (parsed.v === 2 && parsed.from && parsed.msg) {
2401
+ return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
2402
+ }
2403
+ return null;
2404
+ } catch {
2405
+ return null;
2406
+ }
2407
+ }
2348
2408
  var VoidlyAgent = class _VoidlyAgent {
2349
2409
  constructor(identity, config) {
2410
+ this._pinnedDids = /* @__PURE__ */ new Set();
2411
+ this._listeners = /* @__PURE__ */ new Set();
2412
+ this._conversations = /* @__PURE__ */ new Map();
2413
+ this._offlineQueue = [];
2414
+ this._ratchetStates = /* @__PURE__ */ new Map();
2350
2415
  this.did = identity.did;
2351
2416
  this.apiKey = identity.apiKey;
2352
2417
  this.signingKeyPair = identity.signingKeyPair;
2353
2418
  this.encryptionKeyPair = identity.encryptionKeyPair;
2354
2419
  this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
2420
+ this.autoPin = config?.autoPin !== false;
2421
+ this.defaultRetries = config?.retries ?? 3;
2422
+ this.fallbackRelays = config?.fallbackRelays || [];
2423
+ this.paddingEnabled = config?.padding !== false;
2424
+ this.sealedSender = config?.sealedSender || false;
2355
2425
  }
2356
2426
  // ─── Factory Methods ────────────────────────────────────────────────────────
2357
2427
  /**
@@ -2418,16 +2488,55 @@ var VoidlyAgent = class _VoidlyAgent {
2418
2488
  }
2419
2489
  // ─── Messaging ──────────────────────────────────────────────────────────────
2420
2490
  /**
2421
- * Send an E2E encrypted message. Encryption happens locally.
2422
- * The relay server NEVER sees the plaintext or private keys.
2491
+ * Send an E2E encrypted message with hardened security.
2492
+ * Encryption happens locally — the relay NEVER sees plaintext or private keys.
2493
+ *
2494
+ * Security features:
2495
+ * - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
2496
+ * - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
2497
+ * - **Sealed sender** — optionally hide sender DID from relay metadata
2498
+ * - **Auto-retry** with exponential backoff on transient failures
2499
+ * - **Multi-relay fallback** — try backup relays if primary is down
2500
+ * - **Offline queue** — queue messages if all relays fail
2501
+ * - **Transparent TOFU** — auto-pin recipient keys on first contact
2423
2502
  */
2424
2503
  async send(recipientDid, message, options = {}) {
2504
+ const maxRetries = options.retries ?? this.defaultRetries;
2505
+ const usePadding = !options.noPadding && this.paddingEnabled;
2506
+ const useSealed = options.sealedSender ?? this.sealedSender;
2425
2507
  const profile = await this.getIdentity(recipientDid);
2426
2508
  if (!profile) {
2427
2509
  throw new Error(`Recipient ${recipientDid} not found`);
2428
2510
  }
2511
+ if (this.autoPin && !options.skipPin) {
2512
+ await this._autoPinKeys(recipientDid);
2513
+ }
2429
2514
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
2430
- const messageBytes = (0, import_tweetnacl_util.decodeUTF8)(message);
2515
+ let plaintext = message;
2516
+ if (useSealed) {
2517
+ plaintext = sealEnvelope(this.did, message);
2518
+ }
2519
+ let messageBytes;
2520
+ if (usePadding) {
2521
+ messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
2522
+ } else {
2523
+ messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
2524
+ }
2525
+ const pairId = `${this.did}:${recipientDid}`;
2526
+ const ratchetState = this._ratchetStates.get(pairId);
2527
+ let ratchetStep_n = 0;
2528
+ if (ratchetState) {
2529
+ const { nextChainKey, messageKey } = await ratchetStep(ratchetState.chainKey);
2530
+ ratchetState.chainKey = nextChainKey;
2531
+ ratchetState.step++;
2532
+ ratchetStep_n = ratchetState.step;
2533
+ } else {
2534
+ const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
2535
+ this._ratchetStates.set(pairId, {
2536
+ chainKey: sharedSecret,
2537
+ step: 0
2538
+ });
2539
+ }
2431
2540
  const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
2432
2541
  const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
2433
2542
  if (!ciphertext) {
@@ -2441,40 +2550,54 @@ var VoidlyAgent = class _VoidlyAgent {
2441
2550
  ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext))
2442
2551
  });
2443
2552
  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
2449
- },
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();
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
2553
+ const payload = {
2554
+ to: recipientDid,
2555
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
2556
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
2557
+ signature: (0, import_tweetnacl_util.encodeBase64)(signature),
2558
+ envelope: envelopeData,
2559
+ content_type: options.contentType || "text/plain",
2560
+ message_type: options.messageType || "text",
2561
+ thread_id: options.threadId,
2562
+ reply_to: options.replyTo,
2563
+ ttl: options.ttl
2477
2564
  };
2565
+ const relays = [this.baseUrl, ...this.fallbackRelays];
2566
+ let lastError = null;
2567
+ for (const relay of relays) {
2568
+ try {
2569
+ const raw = await this._fetchWithRetry(
2570
+ `${relay}/v1/agent/send/encrypted`,
2571
+ {
2572
+ method: "POST",
2573
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
2574
+ body: JSON.stringify(payload)
2575
+ },
2576
+ { maxRetries, baseDelay: 500, maxDelay: 1e4 }
2577
+ );
2578
+ return {
2579
+ id: raw.id,
2580
+ from: raw.from,
2581
+ to: raw.to,
2582
+ timestamp: raw.timestamp,
2583
+ expiresAt: raw.expires_at || raw.expiresAt,
2584
+ encrypted: raw.encrypted,
2585
+ clientSide: raw.client_side || raw.clientSide
2586
+ };
2587
+ } catch (err) {
2588
+ lastError = err instanceof Error ? err : new Error(String(err));
2589
+ if (lastError.message.includes("(4")) break;
2590
+ }
2591
+ }
2592
+ if (lastError && !lastError.message.includes("(4")) {
2593
+ this._offlineQueue.push({
2594
+ recipientDid,
2595
+ message,
2596
+ options,
2597
+ timestamp: Date.now()
2598
+ });
2599
+ }
2600
+ throw lastError || new Error("Send failed");
2478
2601
  }
2479
2602
  /**
2480
2603
  * Receive and decrypt messages. Decryption happens locally.
@@ -2503,14 +2626,28 @@ var VoidlyAgent = class _VoidlyAgent {
2503
2626
  const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
2504
2627
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
2505
2628
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
2506
- const plaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2507
- if (!plaintext) continue;
2629
+ const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
2630
+ if (!rawPlaintext) continue;
2631
+ let plaintextBytes = rawPlaintext;
2632
+ if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
2633
+ const unpadded = unpadMessage(rawPlaintext);
2634
+ if (unpadded.length < rawPlaintext.length) {
2635
+ plaintextBytes = unpadded;
2636
+ }
2637
+ }
2638
+ let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
2639
+ let senderDid = msg.from;
2640
+ const unsealed = unsealEnvelope(content);
2641
+ if (unsealed) {
2642
+ content = unsealed.msg;
2643
+ senderDid = unsealed.from;
2644
+ }
2508
2645
  let signatureValid = false;
2509
2646
  try {
2510
2647
  const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
2511
2648
  const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
2512
2649
  const envelopeStr = msg.envelope || JSON.stringify({
2513
- from: msg.from,
2650
+ from: senderDid,
2514
2651
  to: msg.to,
2515
2652
  timestamp: msg.timestamp,
2516
2653
  nonce: msg.nonce,
@@ -2526,9 +2663,9 @@ var VoidlyAgent = class _VoidlyAgent {
2526
2663
  }
2527
2664
  decrypted.push({
2528
2665
  id: msg.id,
2529
- from: msg.from,
2666
+ from: senderDid,
2530
2667
  to: msg.to,
2531
- content: (0, import_tweetnacl_util.encodeUTF8)(plaintext),
2668
+ content,
2532
2669
  contentType: msg.content_type,
2533
2670
  messageType: msg.message_type || "text",
2534
2671
  threadId: msg.thread_id,
@@ -3539,9 +3676,498 @@ var VoidlyAgent = class _VoidlyAgent {
3539
3676
  if (!res.ok) throw new Error(`Key verify failed: ${res.status}`);
3540
3677
  return res.json();
3541
3678
  }
3679
+ // ═══════════════════════════════════════════════════════════════════════════
3680
+ // LISTEN — Event-Driven Message Receiving
3681
+ // ═══════════════════════════════════════════════════════════════════════════
3682
+ /**
3683
+ * Listen for incoming messages with an event-driven callback.
3684
+ * Uses adaptive polling — speeds up when messages are flowing, slows down when idle.
3685
+ * Automatically sends heartbeat pings to signal the agent is online.
3686
+ *
3687
+ * @example
3688
+ * ```ts
3689
+ * // Simple listener
3690
+ * const handle = agent.listen((msg) => {
3691
+ * console.log(`${msg.from}: ${msg.content}`);
3692
+ * });
3693
+ *
3694
+ * // Stop after 60 seconds
3695
+ * setTimeout(() => handle.stop(), 60000);
3696
+ *
3697
+ * // With options
3698
+ * const handle = agent.listen(
3699
+ * (msg) => console.log(msg.content),
3700
+ * {
3701
+ * interval: 1000, // poll every 1s
3702
+ * from: 'did:voidly:x', // only from this agent
3703
+ * threadId: 'conv-1', // only this thread
3704
+ * adaptive: true, // slow down when idle
3705
+ * heartbeat: true, // send pings
3706
+ * }
3707
+ * );
3708
+ * ```
3709
+ */
3710
+ listen(onMessage, options = {}, onError) {
3711
+ const interval = Math.max(options.interval || 2e3, 500);
3712
+ const adaptive = options.adaptive !== false;
3713
+ const autoMarkRead = options.autoMarkRead !== false;
3714
+ const unreadOnly = options.unreadOnly !== false;
3715
+ const heartbeat = options.heartbeat !== false;
3716
+ const heartbeatInterval = options.heartbeatInterval || 6e4;
3717
+ let active = true;
3718
+ let currentInterval = interval;
3719
+ let consecutiveEmpty = 0;
3720
+ let lastSeen;
3721
+ let timer = null;
3722
+ let heartbeatTimer = null;
3723
+ const handle = {
3724
+ stop: () => {
3725
+ active = false;
3726
+ if (timer) clearTimeout(timer);
3727
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
3728
+ this._listeners.delete(handle);
3729
+ },
3730
+ get active() {
3731
+ return active;
3732
+ }
3733
+ };
3734
+ this._listeners.add(handle);
3735
+ if (heartbeat) {
3736
+ heartbeatTimer = setInterval(async () => {
3737
+ if (!active) return;
3738
+ try {
3739
+ await this.ping();
3740
+ } catch {
3741
+ }
3742
+ }, heartbeatInterval);
3743
+ this.ping().catch(() => {
3744
+ });
3745
+ }
3746
+ const poll = async () => {
3747
+ if (!active || options.signal?.aborted) {
3748
+ handle.stop();
3749
+ return;
3750
+ }
3751
+ try {
3752
+ const messages = await this.receive({
3753
+ since: lastSeen,
3754
+ from: options.from,
3755
+ threadId: options.threadId,
3756
+ messageType: options.messageType,
3757
+ unreadOnly,
3758
+ limit: 50
3759
+ });
3760
+ if (messages.length > 0) {
3761
+ consecutiveEmpty = 0;
3762
+ if (adaptive) currentInterval = Math.max(interval / 2, 500);
3763
+ for (const msg of messages) {
3764
+ try {
3765
+ await onMessage(msg);
3766
+ if (autoMarkRead) {
3767
+ await this.markRead(msg.id).catch(() => {
3768
+ });
3769
+ }
3770
+ } catch (err) {
3771
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
3772
+ }
3773
+ }
3774
+ lastSeen = messages[messages.length - 1].timestamp;
3775
+ } else {
3776
+ consecutiveEmpty++;
3777
+ if (adaptive && consecutiveEmpty > 3) {
3778
+ currentInterval = Math.min(currentInterval * 1.5, interval * 4);
3779
+ }
3780
+ }
3781
+ } catch (err) {
3782
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
3783
+ currentInterval = Math.min(currentInterval * 2, interval * 8);
3784
+ }
3785
+ if (active && !options.signal?.aborted) {
3786
+ timer = setTimeout(poll, currentInterval);
3787
+ }
3788
+ };
3789
+ poll();
3790
+ return handle;
3791
+ }
3792
+ /**
3793
+ * Listen for messages as an async iterator.
3794
+ * Enables `for await` syntax for message processing.
3795
+ *
3796
+ * @example
3797
+ * ```ts
3798
+ * for await (const msg of agent.messages({ unreadOnly: true })) {
3799
+ * console.log(`${msg.from}: ${msg.content}`);
3800
+ * if (msg.content === 'quit') break;
3801
+ * }
3802
+ * ```
3803
+ */
3804
+ async *messages(options = {}) {
3805
+ const queue = [];
3806
+ let resolve = null;
3807
+ let done = false;
3808
+ const handle = this.listen(
3809
+ (msg) => {
3810
+ queue.push(msg);
3811
+ if (resolve) {
3812
+ resolve();
3813
+ resolve = null;
3814
+ }
3815
+ },
3816
+ { ...options, autoMarkRead: options.autoMarkRead !== false }
3817
+ );
3818
+ options.signal?.addEventListener("abort", () => {
3819
+ done = true;
3820
+ handle.stop();
3821
+ if (resolve) {
3822
+ resolve();
3823
+ resolve = null;
3824
+ }
3825
+ });
3826
+ try {
3827
+ while (!done && !options.signal?.aborted) {
3828
+ if (queue.length > 0) {
3829
+ yield queue.shift();
3830
+ } else {
3831
+ await new Promise((r) => {
3832
+ resolve = r;
3833
+ });
3834
+ }
3835
+ }
3836
+ } finally {
3837
+ handle.stop();
3838
+ }
3839
+ }
3840
+ /**
3841
+ * Stop all active listeners. Useful for clean shutdown.
3842
+ */
3843
+ stopAll() {
3844
+ for (const listener of this._listeners) {
3845
+ listener.stop();
3846
+ }
3847
+ this._listeners.clear();
3848
+ }
3849
+ // ═══════════════════════════════════════════════════════════════════════════
3850
+ // CONVERSATIONS — Thread Management
3851
+ // ═══════════════════════════════════════════════════════════════════════════
3852
+ /**
3853
+ * Start or resume a conversation with another agent.
3854
+ * Automatically manages thread IDs, message history, and reply chains.
3855
+ *
3856
+ * @example
3857
+ * ```ts
3858
+ * const conv = agent.conversation(otherDid);
3859
+ * await conv.say('Hello!');
3860
+ * await conv.say('How are you?');
3861
+ *
3862
+ * // Get full history
3863
+ * const history = await conv.history();
3864
+ *
3865
+ * // Listen for replies in this conversation
3866
+ * conv.onReply((msg) => {
3867
+ * console.log(`Reply: ${msg.content}`);
3868
+ * });
3869
+ * ```
3870
+ */
3871
+ conversation(peerDid, threadId) {
3872
+ const tid = threadId || `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3873
+ const key = `${peerDid}:${tid}`;
3874
+ if (this._conversations.has(key)) {
3875
+ return this._conversations.get(key);
3876
+ }
3877
+ const conv = new Conversation(this, peerDid, tid);
3878
+ this._conversations.set(key, conv);
3879
+ return conv;
3880
+ }
3881
+ // ═══════════════════════════════════════════════════════════════════════════
3882
+ // INTERNAL — Retry, Auto-Pin
3883
+ // ═══════════════════════════════════════════════════════════════════════════
3884
+ /** @internal Auto-pin keys on first contact (TOFU) */
3885
+ async _autoPinKeys(did) {
3886
+ if (this._pinnedDids.has(did)) return;
3887
+ this._pinnedDids.add(did);
3888
+ try {
3889
+ const result = await this.pinKeys(did);
3890
+ if (result.key_changed && result.warning) {
3891
+ console.warn(`[voidly] \u26A0 Key change detected for ${did}: ${result.warning}`);
3892
+ }
3893
+ } catch {
3894
+ }
3895
+ }
3896
+ /** @internal Fetch with exponential backoff retry */
3897
+ async _fetchWithRetry(url, init, retry = {}) {
3898
+ const maxRetries = retry.maxRetries ?? 3;
3899
+ const baseDelay = retry.baseDelay ?? 500;
3900
+ const maxDelay = retry.maxDelay ?? 1e4;
3901
+ let lastError = null;
3902
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3903
+ try {
3904
+ const res = await fetch(url, init);
3905
+ if (res.ok) {
3906
+ return await res.json();
3907
+ }
3908
+ const err = await res.json().catch(() => ({}));
3909
+ const errMsg = err.error?.message || err.error || res.statusText;
3910
+ if (res.status >= 400 && res.status < 500) {
3911
+ throw new Error(`Send failed (${res.status}): ${errMsg}`);
3912
+ }
3913
+ lastError = new Error(`Send failed (${res.status}): ${errMsg}`);
3914
+ } catch (err) {
3915
+ if (err instanceof Error && err.message.startsWith("Send failed (4")) {
3916
+ throw err;
3917
+ }
3918
+ lastError = err instanceof Error ? err : new Error(String(err));
3919
+ }
3920
+ if (attempt < maxRetries) {
3921
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
3922
+ const jitter = delay * (0.5 + Math.random() * 0.5);
3923
+ await new Promise((r) => setTimeout(r, jitter));
3924
+ }
3925
+ }
3926
+ throw lastError || new Error("Send failed after retries");
3927
+ }
3928
+ // ═══════════════════════════════════════════════════════════════════════════
3929
+ // OFFLINE QUEUE — Resilience against relay downtime
3930
+ // ═══════════════════════════════════════════════════════════════════════════
3931
+ /**
3932
+ * Drain the offline message queue — retry sending queued messages.
3933
+ * Call this when connectivity is restored.
3934
+ * Returns: number of messages successfully sent.
3935
+ */
3936
+ async drainQueue() {
3937
+ let sent = 0;
3938
+ let failed = 0;
3939
+ const remaining = [];
3940
+ for (const item of this._offlineQueue) {
3941
+ if (Date.now() - item.timestamp > 864e5) {
3942
+ failed++;
3943
+ continue;
3944
+ }
3945
+ try {
3946
+ await this.send(item.recipientDid, item.message, item.options);
3947
+ sent++;
3948
+ } catch {
3949
+ remaining.push(item);
3950
+ failed++;
3951
+ }
3952
+ }
3953
+ this._offlineQueue = remaining;
3954
+ return { sent, failed, remaining: remaining.length };
3955
+ }
3956
+ /** Number of messages waiting in the offline queue */
3957
+ get queueLength() {
3958
+ return this._offlineQueue.length;
3959
+ }
3960
+ // ═══════════════════════════════════════════════════════════════════════════
3961
+ // SECURITY REPORT — Transparent threat model
3962
+ // ═══════════════════════════════════════════════════════════════════════════
3963
+ /**
3964
+ * Returns what the relay can and cannot see about this agent.
3965
+ * Call this to understand your threat model. Total transparency.
3966
+ */
3967
+ threatModel() {
3968
+ return {
3969
+ relayCanSee: [
3970
+ "Your DID (public identifier)",
3971
+ "Who you message (recipient DIDs)",
3972
+ "When you message (timestamps)",
3973
+ "Message types (text, task-request, etc.)",
3974
+ "Thread structure (which messages are replies)",
3975
+ "Channel membership",
3976
+ "Capability registrations",
3977
+ "Online/offline status",
3978
+ "Approximate message size (even with padding, bounded to power-of-2)"
3979
+ ],
3980
+ relayCannotSee: [
3981
+ "Message content (E2E encrypted with NaCl box)",
3982
+ "Private keys (generated and stored client-side only)",
3983
+ "Memory values (encrypted with key derived from your API key)",
3984
+ "Attestation content (signed but readable by anyone with the attestation)",
3985
+ ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
3986
+ ],
3987
+ protections: [
3988
+ "X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
3989
+ "Ed25519 signatures on every message",
3990
+ "TOFU key pinning (MitM detection on key change)",
3991
+ ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
3992
+ ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
3993
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
3994
+ "Auto-retry with exponential backoff",
3995
+ "Offline message queue"
3996
+ ],
3997
+ gaps: [
3998
+ "No forward secrecy \u2014 same keypair for all messages (compromise exposes ALL history)",
3999
+ "No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later",
4000
+ "Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
4001
+ "Metadata (who, when, thread structure) visible to relay operator",
4002
+ "Single relay architecture (no onion routing, no mix network)",
4003
+ "Ed25519 signatures are non-repudiable (no deniable messaging)",
4004
+ "No async key agreement (no X3DH prekeys)",
4005
+ "Polling-based (no WebSocket real-time transport)"
4006
+ ]
4007
+ };
4008
+ }
4009
+ };
4010
+ var Conversation = class {
4011
+ /** @internal */
4012
+ constructor(agent, peerDid, threadId) {
4013
+ this._lastMessageId = null;
4014
+ this._messageHistory = [];
4015
+ this._listener = null;
4016
+ this._replyHandlers = [];
4017
+ this.agent = agent;
4018
+ this.peerDid = peerDid;
4019
+ this.threadId = threadId;
4020
+ }
4021
+ /**
4022
+ * Send a message in this conversation. Auto-threaded and auto-linked to previous message.
4023
+ */
4024
+ async say(content, options) {
4025
+ const result = await this.agent.send(this.peerDid, content, {
4026
+ ...options,
4027
+ threadId: this.threadId,
4028
+ replyTo: this._lastMessageId || void 0
4029
+ });
4030
+ this._lastMessageId = result.id;
4031
+ this._messageHistory.push({
4032
+ id: result.id,
4033
+ from: this.agent.did,
4034
+ content,
4035
+ timestamp: result.timestamp,
4036
+ signatureValid: true,
4037
+ messageType: options?.messageType || "text"
4038
+ });
4039
+ return result;
4040
+ }
4041
+ /**
4042
+ * Get conversation history (both sent and received messages in this thread).
4043
+ */
4044
+ async history(options) {
4045
+ const received = await this.agent.receive({
4046
+ threadId: this.threadId,
4047
+ from: this.peerDid,
4048
+ limit: options?.limit || 100
4049
+ });
4050
+ const all = [
4051
+ ...this._messageHistory,
4052
+ ...received.map((m) => ({
4053
+ id: m.id,
4054
+ from: m.from,
4055
+ content: m.content,
4056
+ timestamp: m.timestamp,
4057
+ signatureValid: m.signatureValid,
4058
+ messageType: m.messageType
4059
+ }))
4060
+ ];
4061
+ const seen = /* @__PURE__ */ new Set();
4062
+ return all.filter((m) => {
4063
+ if (seen.has(m.id)) return false;
4064
+ seen.add(m.id);
4065
+ return true;
4066
+ }).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
4067
+ }
4068
+ /**
4069
+ * Register a callback for replies in this conversation.
4070
+ */
4071
+ onReply(handler) {
4072
+ this._replyHandlers.push(handler);
4073
+ if (!this._listener) {
4074
+ this._listener = this.agent.listen(
4075
+ async (msg) => {
4076
+ this._messageHistory.push({
4077
+ id: msg.id,
4078
+ from: msg.from,
4079
+ content: msg.content,
4080
+ timestamp: msg.timestamp,
4081
+ signatureValid: msg.signatureValid,
4082
+ messageType: msg.messageType
4083
+ });
4084
+ this._lastMessageId = msg.id;
4085
+ for (const h of this._replyHandlers) {
4086
+ try {
4087
+ await h(msg);
4088
+ } catch {
4089
+ }
4090
+ }
4091
+ },
4092
+ { from: this.peerDid, threadId: this.threadId, autoMarkRead: true }
4093
+ );
4094
+ }
4095
+ }
4096
+ /**
4097
+ * Wait for the next reply in this conversation (Promise-based).
4098
+ *
4099
+ * @param timeoutMs - Maximum time to wait (default: 30000ms)
4100
+ * @throws Error on timeout
4101
+ */
4102
+ async waitForReply(timeoutMs = 3e4) {
4103
+ return new Promise((resolve, reject) => {
4104
+ let resolved = false;
4105
+ const timeout = setTimeout(() => {
4106
+ if (!resolved) {
4107
+ resolved = true;
4108
+ reject(new Error(`No reply received within ${timeoutMs}ms`));
4109
+ }
4110
+ }, timeoutMs);
4111
+ const check = async () => {
4112
+ while (!resolved) {
4113
+ const messages = await this.agent.receive({
4114
+ from: this.peerDid,
4115
+ threadId: this.threadId,
4116
+ unreadOnly: true,
4117
+ limit: 1
4118
+ });
4119
+ if (messages.length > 0 && !resolved) {
4120
+ resolved = true;
4121
+ clearTimeout(timeout);
4122
+ const msg = messages[0];
4123
+ this._messageHistory.push({
4124
+ id: msg.id,
4125
+ from: msg.from,
4126
+ content: msg.content,
4127
+ timestamp: msg.timestamp,
4128
+ signatureValid: msg.signatureValid,
4129
+ messageType: msg.messageType
4130
+ });
4131
+ this._lastMessageId = msg.id;
4132
+ await this.agent.markRead(msg.id).catch(() => {
4133
+ });
4134
+ resolve(msg);
4135
+ return;
4136
+ }
4137
+ await new Promise((r) => setTimeout(r, 1500));
4138
+ }
4139
+ };
4140
+ check().catch((err) => {
4141
+ if (!resolved) {
4142
+ resolved = true;
4143
+ clearTimeout(timeout);
4144
+ reject(err);
4145
+ }
4146
+ });
4147
+ });
4148
+ }
4149
+ /**
4150
+ * Stop listening for replies and clean up.
4151
+ */
4152
+ close() {
4153
+ if (this._listener) {
4154
+ this._listener.stop();
4155
+ this._listener = null;
4156
+ }
4157
+ this._replyHandlers = [];
4158
+ }
4159
+ /** Number of messages tracked locally */
4160
+ get length() {
4161
+ return this._messageHistory.length;
4162
+ }
4163
+ /** The last message in this conversation */
4164
+ get lastMessage() {
4165
+ return this._messageHistory.length > 0 ? this._messageHistory[this._messageHistory.length - 1] : null;
4166
+ }
3542
4167
  };
3543
4168
  // Annotate the CommonJS export names for ESM import in node:
3544
4169
  0 && (module.exports = {
4170
+ Conversation,
3545
4171
  VoidlyAgent,
3546
4172
  decodeBase64,
3547
4173
  decodeUTF8,