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