@voidly/agent-sdk 3.0.0 → 3.2.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
@@ -3983,19 +3983,29 @@ async function ratchetStep(chainKey) {
3983
3983
  );
3984
3984
  return { nextChainKey, messageKey };
3985
3985
  }
3986
- function sealEnvelope(senderDid, plaintext) {
3987
- return JSON.stringify({
3988
- v: 2,
3986
+ function sealEnvelope(senderDid, plaintext, meta) {
3987
+ const obj = {
3988
+ v: 3,
3989
3989
  from: senderDid,
3990
3990
  msg: plaintext,
3991
3991
  ts: (/* @__PURE__ */ new Date()).toISOString()
3992
- });
3992
+ };
3993
+ if (meta?.contentType && meta.contentType !== "text/plain") obj.ct = meta.contentType;
3994
+ if (meta?.messageType && meta.messageType !== "text") obj.mt = meta.messageType;
3995
+ if (meta?.threadId) obj.tid = meta.threadId;
3996
+ if (meta?.replyTo) obj.rto = meta.replyTo;
3997
+ return JSON.stringify(obj);
3993
3998
  }
3994
3999
  function unsealEnvelope(plaintext) {
3995
4000
  try {
3996
4001
  const parsed = JSON.parse(plaintext);
3997
- if (parsed.v === 2 && parsed.from && parsed.msg) {
3998
- return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
4002
+ if ((parsed.v === 2 || parsed.v === 3) && parsed.from && parsed.msg) {
4003
+ const result = { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
4004
+ if (parsed.ct) result.contentType = parsed.ct;
4005
+ if (parsed.mt) result.messageType = parsed.mt;
4006
+ if (parsed.tid) result.threadId = parsed.tid;
4007
+ if (parsed.rto) result.replyTo = parsed.rto;
4008
+ return result;
3999
4009
  }
4000
4010
  return null;
4001
4011
  } catch {
@@ -4073,6 +4083,17 @@ var VoidlyAgent = class _VoidlyAgent {
4073
4083
  this._identityCache = /* @__PURE__ */ new Map();
4074
4084
  this._seenMessageIds = /* @__PURE__ */ new Set();
4075
4085
  this._decryptFailCount = 0;
4086
+ // RPC handlers: method → handler function
4087
+ this._rpcHandlers = /* @__PURE__ */ new Map();
4088
+ // RPC pending responses: rpc_id → { resolve, reject, timer }
4089
+ this._rpcPending = /* @__PURE__ */ new Map();
4090
+ // Cover traffic state
4091
+ this._coverTrafficTimer = null;
4092
+ // RPC listener handle (started on first onInvoke)
4093
+ this._rpcListener = null;
4094
+ // Persistence (v3.2)
4095
+ this._persistMode = "memory";
4096
+ this._persistKey = null;
4076
4097
  this.did = identity.did;
4077
4098
  this.apiKey = identity.apiKey;
4078
4099
  this.signingKeyPair = identity.signingKeyPair;
@@ -4092,6 +4113,11 @@ var VoidlyAgent = class _VoidlyAgent {
4092
4113
  this.longPoll = config?.longPoll !== false;
4093
4114
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4094
4115
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4116
+ this._persistMode = config?.persist || "memory";
4117
+ this._onPersist = config?.onPersist;
4118
+ this._onLoad = config?.onLoad;
4119
+ this._persistPath = config?.persistPath;
4120
+ this._transportPrefs = config?.transport || ["sse", "long-poll"];
4095
4121
  }
4096
4122
  // ─── Factory Methods ────────────────────────────────────────────────────────
4097
4123
  /**
@@ -4296,6 +4322,186 @@ var VoidlyAgent = class _VoidlyAgent {
4296
4322
  } : {}
4297
4323
  };
4298
4324
  }
4325
+ // ─── Ratchet Persistence (v3.2) ────────────────────────────────────────────
4326
+ /** Derive persistence encryption key from signing secret */
4327
+ _derivePersistKey() {
4328
+ if (this._persistKey) return this._persistKey;
4329
+ const salt = (0, import_tweetnacl_util.decodeUTF8)("voidly-persist-v1");
4330
+ const input = new Uint8Array(this.signingKeyPair.secretKey.length + salt.length);
4331
+ input.set(this.signingKeyPair.secretKey, 0);
4332
+ input.set(salt, this.signingKeyPair.secretKey.length);
4333
+ this._persistKey = import_tweetnacl.default.hash(input).slice(0, 32);
4334
+ return this._persistKey;
4335
+ }
4336
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
4337
+ async _persistRatchetState() {
4338
+ if (this._persistMode === "memory") return;
4339
+ try {
4340
+ const creds = this.exportCredentials();
4341
+ const data = JSON.stringify(creds.ratchetStates || {});
4342
+ const key = this._derivePersistKey();
4343
+ const nonce = import_tweetnacl.default.randomBytes(24);
4344
+ const encrypted = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(data), nonce, key);
4345
+ const blob = JSON.stringify({ n: (0, import_tweetnacl_util.encodeBase64)(nonce), c: (0, import_tweetnacl_util.encodeBase64)(encrypted), v: 1 });
4346
+ switch (this._persistMode) {
4347
+ case "localStorage":
4348
+ if (typeof localStorage !== "undefined") {
4349
+ localStorage.setItem(`voidly-ratchet-${this.did}`, blob);
4350
+ }
4351
+ break;
4352
+ case "indexedDB":
4353
+ await this._idbPut(blob);
4354
+ break;
4355
+ case "file":
4356
+ if (this._persistPath) {
4357
+ const fs = await import("fs/promises");
4358
+ await fs.writeFile(this._persistPath, blob, "utf-8");
4359
+ }
4360
+ break;
4361
+ case "relay":
4362
+ await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4363
+ method: "PUT",
4364
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
4365
+ body: JSON.stringify({ value: blob })
4366
+ }).catch(() => {
4367
+ });
4368
+ break;
4369
+ case "custom":
4370
+ if (this._onPersist) await this._onPersist(blob);
4371
+ break;
4372
+ }
4373
+ } catch {
4374
+ }
4375
+ }
4376
+ /** Load persisted ratchet state and restore into memory */
4377
+ async _loadPersistedRatchetState() {
4378
+ if (this._persistMode === "memory") return;
4379
+ let blob = null;
4380
+ try {
4381
+ switch (this._persistMode) {
4382
+ case "localStorage":
4383
+ if (typeof localStorage !== "undefined") {
4384
+ blob = localStorage.getItem(`voidly-ratchet-${this.did}`);
4385
+ }
4386
+ break;
4387
+ case "indexedDB":
4388
+ blob = await this._idbGet();
4389
+ break;
4390
+ case "file":
4391
+ if (this._persistPath) {
4392
+ try {
4393
+ const fs = await import("fs/promises");
4394
+ blob = await fs.readFile(this._persistPath, "utf-8");
4395
+ } catch {
4396
+ }
4397
+ }
4398
+ break;
4399
+ case "relay":
4400
+ try {
4401
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4402
+ headers: { "X-Agent-Key": this.apiKey }
4403
+ });
4404
+ if (res.ok) {
4405
+ const data = await res.json();
4406
+ blob = data.value;
4407
+ }
4408
+ } catch {
4409
+ }
4410
+ break;
4411
+ case "custom":
4412
+ if (this._onLoad) blob = await this._onLoad();
4413
+ break;
4414
+ }
4415
+ } catch {
4416
+ return;
4417
+ }
4418
+ if (!blob) return;
4419
+ try {
4420
+ const { n, c } = JSON.parse(blob);
4421
+ const key = this._derivePersistKey();
4422
+ const decrypted = import_tweetnacl.default.secretbox.open((0, import_tweetnacl_util.decodeBase64)(c), (0, import_tweetnacl_util.decodeBase64)(n), key);
4423
+ if (!decrypted) return;
4424
+ const states = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(decrypted));
4425
+ for (const [pairId, rs] of Object.entries(states)) {
4426
+ if (this._ratchetStates.has(pairId)) continue;
4427
+ const state = {
4428
+ sendChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey),
4429
+ sendStep: rs.sendStep,
4430
+ recvChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey),
4431
+ recvStep: rs.recvStep,
4432
+ skippedKeys: /* @__PURE__ */ new Map()
4433
+ };
4434
+ if (rs.rootKey) state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4435
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4436
+ state.dhSendKeyPair = {
4437
+ secretKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey),
4438
+ publicKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey)
4439
+ };
4440
+ }
4441
+ if (rs.dhRecvPubKey) state.dhRecvPubKey = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4442
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4443
+ if (state.sendChainKey.length === 32 && state.recvChainKey.length === 32) {
4444
+ this._ratchetStates.set(pairId, state);
4445
+ }
4446
+ }
4447
+ } catch {
4448
+ }
4449
+ }
4450
+ /** IndexedDB put helper (browser only) */
4451
+ async _idbPut(blob) {
4452
+ if (typeof indexedDB === "undefined") return;
4453
+ return new Promise((resolve, reject) => {
4454
+ const req = indexedDB.open("voidly-agent", 1);
4455
+ req.onupgradeneeded = () => {
4456
+ const db = req.result;
4457
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4458
+ };
4459
+ req.onsuccess = () => {
4460
+ const tx = req.result.transaction("ratchet", "readwrite");
4461
+ tx.objectStore("ratchet").put(blob, this.did);
4462
+ tx.oncomplete = () => resolve();
4463
+ tx.onerror = () => reject(tx.error);
4464
+ };
4465
+ req.onerror = () => reject(req.error);
4466
+ });
4467
+ }
4468
+ /** IndexedDB get helper (browser only) */
4469
+ async _idbGet() {
4470
+ if (typeof indexedDB === "undefined") return null;
4471
+ return new Promise((resolve, reject) => {
4472
+ const req = indexedDB.open("voidly-agent", 1);
4473
+ req.onupgradeneeded = () => {
4474
+ const db = req.result;
4475
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4476
+ };
4477
+ req.onsuccess = () => {
4478
+ const tx = req.result.transaction("ratchet", "readonly");
4479
+ const getReq = tx.objectStore("ratchet").get(this.did);
4480
+ getReq.onsuccess = () => resolve(getReq.result || null);
4481
+ getReq.onerror = () => reject(getReq.error);
4482
+ };
4483
+ req.onerror = () => reject(req.error);
4484
+ });
4485
+ }
4486
+ /**
4487
+ * Force-persist current ratchet state.
4488
+ * Useful to call before process exit to ensure state is saved.
4489
+ */
4490
+ async flushRatchetState() {
4491
+ const origMode = this._persistMode;
4492
+ if (origMode === "memory") this._persistMode = "file";
4493
+ await this._persistRatchetState();
4494
+ this._persistMode = origMode;
4495
+ }
4496
+ /**
4497
+ * Restore an agent from credentials with async persistence loading.
4498
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
4499
+ */
4500
+ static async fromCredentialsAsync(creds, config) {
4501
+ const agent = _VoidlyAgent.fromCredentials(creds, config);
4502
+ await agent._loadPersistedRatchetState();
4503
+ return agent;
4504
+ }
4299
4505
  /**
4300
4506
  * Get the number of messages that failed to decrypt.
4301
4507
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -4343,7 +4549,12 @@ var VoidlyAgent = class _VoidlyAgent {
4343
4549
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
4344
4550
  let plaintext = message;
4345
4551
  if (useSealed) {
4346
- plaintext = sealEnvelope(this.did, message);
4552
+ plaintext = sealEnvelope(this.did, message, {
4553
+ contentType: options.contentType,
4554
+ messageType: options.messageType,
4555
+ threadId: options.threadId,
4556
+ replyTo: options.replyTo
4557
+ });
4347
4558
  }
4348
4559
  let contentBytes;
4349
4560
  if (usePadding) {
@@ -4458,12 +4669,16 @@ var VoidlyAgent = class _VoidlyAgent {
4458
4669
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
4459
4670
  signature: (0, import_tweetnacl_util.encodeBase64)(signature),
4460
4671
  envelope: envelopeData,
4461
- content_type: options.contentType || "text/plain",
4462
- message_type: options.messageType || "text",
4463
- thread_id: options.threadId,
4464
- reply_to: options.replyTo,
4465
4672
  ttl: options.ttl
4466
4673
  };
4674
+ if (!useSealed) {
4675
+ payload.content_type = options.contentType || "text/plain";
4676
+ payload.message_type = options.messageType || "text";
4677
+ payload.thread_id = options.threadId;
4678
+ payload.reply_to = options.replyTo;
4679
+ }
4680
+ this._persistRatchetState().catch(() => {
4681
+ });
4467
4682
  const relays = [this.baseUrl, ...this.fallbackRelays];
4468
4683
  let lastError = null;
4469
4684
  for (const relay of relays) {
@@ -4514,7 +4729,7 @@ var VoidlyAgent = class _VoidlyAgent {
4514
4729
  if (options.contentType) params.set("content_type", options.contentType);
4515
4730
  if (options.messageType) params.set("message_type", options.messageType);
4516
4731
  if (options.unreadOnly) params.set("unread", "true");
4517
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/raw?${params}`, {
4732
+ const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
4518
4733
  headers: { "X-Agent-Key": this.apiKey }
4519
4734
  });
4520
4735
  if (!res.ok) {
@@ -4522,11 +4737,28 @@ var VoidlyAgent = class _VoidlyAgent {
4522
4737
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4523
4738
  }
4524
4739
  const data = await res.json();
4740
+ return this._decryptMessages(data.messages);
4741
+ }
4742
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4743
+ async _decryptMessages(rawMessages) {
4525
4744
  const decrypted = [];
4526
- for (const msg of data.messages) {
4745
+ for (const msg of rawMessages) {
4527
4746
  try {
4528
4747
  if (this._seenMessageIds.has(msg.id)) continue;
4529
- const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4748
+ let senderEncPub;
4749
+ let senderSignPubBytes = null;
4750
+ if (msg.sender_encryption_key) {
4751
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4752
+ if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4753
+ } else if (msg.envelope) {
4754
+ const env = JSON.parse(msg.envelope);
4755
+ const senderProfile = await this.getIdentity(env.from);
4756
+ if (!senderProfile) continue;
4757
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
4758
+ if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
4759
+ } else {
4760
+ continue;
4761
+ }
4530
4762
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
4531
4763
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
4532
4764
  let rawPlaintext = null;
@@ -4534,7 +4766,6 @@ var VoidlyAgent = class _VoidlyAgent {
4534
4766
  let envelopePqCiphertext = null;
4535
4767
  let envelopeDhRatchetKey = null;
4536
4768
  let envelopePn = 0;
4537
- let envelopeDeniable = false;
4538
4769
  if (msg.envelope) {
4539
4770
  try {
4540
4771
  const env = JSON.parse(msg.envelope);
@@ -4694,11 +4925,19 @@ var VoidlyAgent = class _VoidlyAgent {
4694
4925
  }
4695
4926
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
4696
4927
  let senderDid = msg.from;
4928
+ let innerContentType;
4929
+ let innerMessageType;
4930
+ let innerThreadId;
4931
+ let innerReplyTo;
4697
4932
  if (wasSealed || !proto) {
4698
4933
  const unsealed = unsealEnvelope(content);
4699
4934
  if (unsealed) {
4700
4935
  content = unsealed.msg;
4701
4936
  senderDid = unsealed.from;
4937
+ innerContentType = unsealed.contentType;
4938
+ innerMessageType = unsealed.messageType;
4939
+ innerThreadId = unsealed.threadId;
4940
+ innerReplyTo = unsealed.replyTo;
4702
4941
  }
4703
4942
  }
4704
4943
  let signatureValid = false;
@@ -4719,12 +4958,11 @@ var VoidlyAgent = class _VoidlyAgent {
4719
4958
  for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4720
4959
  signatureValid = diff === 0;
4721
4960
  }
4722
- } else {
4723
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4961
+ } else if (senderSignPubBytes) {
4724
4962
  signatureValid = import_tweetnacl.default.sign.detached.verify(
4725
4963
  (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4726
4964
  signatureBytes,
4727
- senderSignPub
4965
+ senderSignPubBytes
4728
4966
  );
4729
4967
  }
4730
4968
  } catch {
@@ -4744,10 +4982,11 @@ var VoidlyAgent = class _VoidlyAgent {
4744
4982
  from: senderDid,
4745
4983
  to: msg.to,
4746
4984
  content,
4747
- contentType: msg.content_type,
4748
- messageType: msg.message_type || "text",
4749
- threadId: msg.thread_id,
4750
- replyTo: msg.reply_to,
4985
+ // v3: prefer metadata from inside ciphertext (relay can't see it)
4986
+ contentType: innerContentType || msg.content_type || "text/plain",
4987
+ messageType: innerMessageType || msg.message_type || "text",
4988
+ threadId: innerThreadId || msg.thread_id || null,
4989
+ replyTo: innerReplyTo || msg.reply_to || null,
4751
4990
  signatureValid,
4752
4991
  timestamp: msg.timestamp,
4753
4992
  expiresAt: msg.expires_at
@@ -4756,6 +4995,10 @@ var VoidlyAgent = class _VoidlyAgent {
4756
4995
  this._decryptFailCount++;
4757
4996
  }
4758
4997
  }
4998
+ if (decrypted.length > 0) {
4999
+ this._persistRatchetState().catch(() => {
5000
+ });
5001
+ }
4759
5002
  return decrypted;
4760
5003
  }
4761
5004
  // ─── Message Management ─────────────────────────────────────────────────────
@@ -4808,7 +5051,7 @@ var VoidlyAgent = class _VoidlyAgent {
4808
5051
  if (cached && Date.now() - cached.cachedAt < 3e5) {
4809
5052
  return cached.profile;
4810
5053
  }
4811
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/identity/${did}`);
5054
+ const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
4812
5055
  if (!res.ok) return null;
4813
5056
  const profile = await res.json();
4814
5057
  this._identityCache.set(did, { profile, cachedAt: Date.now() });
@@ -4826,7 +5069,7 @@ var VoidlyAgent = class _VoidlyAgent {
4826
5069
  if (options.query) params.set("query", options.query);
4827
5070
  if (options.capability) params.set("capability", options.capability);
4828
5071
  if (options.limit) params.set("limit", String(options.limit));
4829
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/discover?${params}`);
5072
+ const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
4830
5073
  if (!res.ok) return [];
4831
5074
  const data = await res.json();
4832
5075
  return data.agents;
@@ -6044,6 +6287,84 @@ var VoidlyAgent = class _VoidlyAgent {
6044
6287
  this.ping().catch(() => {
6045
6288
  });
6046
6289
  }
6290
+ const deliverMessages = async (messages) => {
6291
+ for (const msg of messages) {
6292
+ try {
6293
+ await onMessage(msg);
6294
+ if (autoMarkRead) {
6295
+ await this.markRead(msg.id).catch(() => {
6296
+ });
6297
+ }
6298
+ } catch (err) {
6299
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6300
+ }
6301
+ }
6302
+ if (messages.length > 0) {
6303
+ lastSeen = messages[messages.length - 1].timestamp;
6304
+ }
6305
+ };
6306
+ const startSSE = async () => {
6307
+ try {
6308
+ const params = new URLSearchParams();
6309
+ if (lastSeen) params.set("since", lastSeen);
6310
+ if (options.from) params.set("from", options.from);
6311
+ const sseUrl = `${this.baseUrl}/v1/agent/receive/sse?${params}`;
6312
+ const res = await this._timedFetch(sseUrl, {
6313
+ headers: { "X-Agent-Key": this.apiKey }
6314
+ });
6315
+ if (!res.ok || !res.body) return false;
6316
+ const reader = res.body.getReader();
6317
+ const decoder = new TextDecoder();
6318
+ let buffer = "";
6319
+ while (active && !options.signal?.aborted) {
6320
+ const { done: streamDone, value } = await reader.read();
6321
+ if (streamDone) break;
6322
+ buffer += decoder.decode(value, { stream: true });
6323
+ const lines = buffer.split("\n");
6324
+ buffer = lines.pop() || "";
6325
+ let eventType = "";
6326
+ let dataStr = "";
6327
+ for (const line of lines) {
6328
+ if (line.startsWith("event: ")) {
6329
+ eventType = line.slice(7).trim();
6330
+ } else if (line.startsWith("data: ")) {
6331
+ dataStr = line.slice(6);
6332
+ } else if (line === "" && dataStr) {
6333
+ if (eventType === "message" && dataStr) {
6334
+ try {
6335
+ const rawMsg = JSON.parse(dataStr);
6336
+ const decrypted = await this._decryptMessages([rawMsg]);
6337
+ if (decrypted.length > 0) {
6338
+ consecutiveEmpty = 0;
6339
+ await deliverMessages(decrypted);
6340
+ }
6341
+ } catch {
6342
+ }
6343
+ } else if (eventType === "reconnect") {
6344
+ break;
6345
+ }
6346
+ eventType = "";
6347
+ dataStr = "";
6348
+ }
6349
+ }
6350
+ }
6351
+ reader.releaseLock();
6352
+ return true;
6353
+ } catch {
6354
+ return false;
6355
+ }
6356
+ };
6357
+ const sseLoop = async () => {
6358
+ while (active && !options.signal?.aborted) {
6359
+ const ok = await startSSE();
6360
+ if (!active || options.signal?.aborted) break;
6361
+ if (!ok) {
6362
+ poll();
6363
+ return;
6364
+ }
6365
+ await new Promise((r) => setTimeout(r, 500));
6366
+ }
6367
+ };
6047
6368
  const useLongPoll = this.longPoll;
6048
6369
  const poll = async () => {
6049
6370
  if (!active || options.signal?.aborted) {
@@ -6051,62 +6372,18 @@ var VoidlyAgent = class _VoidlyAgent {
6051
6372
  return;
6052
6373
  }
6053
6374
  try {
6054
- let messages;
6055
- if (useLongPoll) {
6056
- const params = new URLSearchParams({ timeout: "25" });
6057
- if (lastSeen) params.set("since", lastSeen);
6058
- if (options.from) params.set("from", options.from);
6059
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6060
- headers: { "X-Agent-Key": this.apiKey }
6061
- });
6062
- if (res.ok) {
6063
- const data = await res.json();
6064
- messages = [];
6065
- for (const raw of data.messages) {
6066
- try {
6067
- if (this._seenMessageIds.has(raw.id)) continue;
6068
- this._seenMessageIds.add(raw.id);
6069
- } catch {
6070
- }
6071
- }
6072
- if (data.messages.length > 0) {
6073
- messages = await this.receive({
6074
- since: lastSeen,
6075
- from: options.from,
6076
- threadId: options.threadId,
6077
- messageType: options.messageType,
6078
- unreadOnly,
6079
- limit: 50
6080
- });
6081
- }
6082
- } else {
6083
- messages = [];
6084
- }
6085
- } else {
6086
- messages = await this.receive({
6087
- since: lastSeen,
6088
- from: options.from,
6089
- threadId: options.threadId,
6090
- messageType: options.messageType,
6091
- unreadOnly,
6092
- limit: 50
6093
- });
6094
- }
6375
+ const messages = await this.receive({
6376
+ since: lastSeen,
6377
+ from: options.from,
6378
+ threadId: options.threadId,
6379
+ messageType: options.messageType,
6380
+ unreadOnly,
6381
+ limit: 50
6382
+ });
6095
6383
  if (messages.length > 0) {
6096
6384
  consecutiveEmpty = 0;
6097
6385
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
6098
- for (const msg of messages) {
6099
- try {
6100
- await onMessage(msg);
6101
- if (autoMarkRead) {
6102
- await this.markRead(msg.id).catch(() => {
6103
- });
6104
- }
6105
- } catch (err) {
6106
- if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6107
- }
6108
- }
6109
- lastSeen = messages[messages.length - 1].timestamp;
6386
+ await deliverMessages(messages);
6110
6387
  } else {
6111
6388
  consecutiveEmpty++;
6112
6389
  if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
@@ -6121,7 +6398,12 @@ var VoidlyAgent = class _VoidlyAgent {
6121
6398
  timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
6122
6399
  }
6123
6400
  };
6124
- poll();
6401
+ const prefs = this._transportPrefs;
6402
+ if (prefs.includes("sse")) {
6403
+ sseLoop();
6404
+ } else {
6405
+ poll();
6406
+ }
6125
6407
  return handle;
6126
6408
  }
6127
6409
  /**
@@ -6180,6 +6462,285 @@ var VoidlyAgent = class _VoidlyAgent {
6180
6462
  listener.stop();
6181
6463
  }
6182
6464
  this._listeners.clear();
6465
+ if (this._rpcListener) {
6466
+ this._rpcListener.stop();
6467
+ this._rpcListener = null;
6468
+ }
6469
+ for (const [id, pending] of this._rpcPending) {
6470
+ clearTimeout(pending.timer);
6471
+ pending.reject(new Error("Agent stopped"));
6472
+ }
6473
+ this._rpcPending.clear();
6474
+ this.disableCoverTraffic();
6475
+ }
6476
+ // ═══════════════════════════════════════════════════════════════════════════
6477
+ // AGENT RPC — Synchronous Function Invocation Between Agents
6478
+ // ═══════════════════════════════════════════════════════════════════════════
6479
+ /**
6480
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
6481
+ * The remote agent must have registered a handler via `onInvoke()`.
6482
+ *
6483
+ * @example
6484
+ * ```ts
6485
+ * // Call a translator agent
6486
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
6487
+ * text: 'Hello, world!',
6488
+ * to: 'ja',
6489
+ * });
6490
+ * console.log(result.translation); // こんにちは
6491
+ *
6492
+ * // With timeout
6493
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
6494
+ * ```
6495
+ */
6496
+ async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
6497
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6498
+ return new Promise(async (resolve, reject) => {
6499
+ const timer = setTimeout(() => {
6500
+ this._rpcPending.delete(rpcId);
6501
+ reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
6502
+ }, timeoutMs);
6503
+ this._rpcPending.set(rpcId, { resolve, reject, timer });
6504
+ try {
6505
+ await this.send(targetDid, JSON.stringify({
6506
+ jsonrpc: "2.0",
6507
+ method,
6508
+ params,
6509
+ id: rpcId
6510
+ }), { messageType: "rpc-request", threadId: rpcId });
6511
+ } catch (err) {
6512
+ clearTimeout(timer);
6513
+ this._rpcPending.delete(rpcId);
6514
+ reject(err);
6515
+ }
6516
+ });
6517
+ }
6518
+ /**
6519
+ * Register a handler for incoming RPC invocations.
6520
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
6521
+ *
6522
+ * @example
6523
+ * ```ts
6524
+ * // Register a translation capability
6525
+ * agent.onInvoke('translate', async (params, callerDid) => {
6526
+ * const result = await myTranslateFunction(params.text, params.to);
6527
+ * return { translation: result };
6528
+ * });
6529
+ *
6530
+ * // Register a search capability
6531
+ * agent.onInvoke('search', async (params) => {
6532
+ * return { results: await searchDatabase(params.query) };
6533
+ * });
6534
+ * ```
6535
+ */
6536
+ onInvoke(method, handler) {
6537
+ this._rpcHandlers.set(method, handler);
6538
+ this._ensureRpcListener();
6539
+ }
6540
+ /**
6541
+ * Remove an RPC handler.
6542
+ */
6543
+ offInvoke(method) {
6544
+ this._rpcHandlers.delete(method);
6545
+ if (this._rpcHandlers.size === 0 && this._rpcListener) {
6546
+ this._rpcListener.stop();
6547
+ this._rpcListener = null;
6548
+ }
6549
+ }
6550
+ /** @internal Start listening for RPC requests and responses */
6551
+ _ensureRpcListener() {
6552
+ if (this._rpcListener) return;
6553
+ this._rpcListener = this.listen(async (msg) => {
6554
+ try {
6555
+ const payload = JSON.parse(msg.content);
6556
+ if (payload.jsonrpc !== "2.0") return;
6557
+ if (payload.id && (payload.result !== void 0 || payload.error)) {
6558
+ const pending = this._rpcPending.get(payload.id);
6559
+ if (pending) {
6560
+ clearTimeout(pending.timer);
6561
+ this._rpcPending.delete(payload.id);
6562
+ if (payload.error) {
6563
+ pending.reject(new Error(payload.error.message || "RPC error"));
6564
+ } else {
6565
+ pending.resolve(payload.result);
6566
+ }
6567
+ }
6568
+ return;
6569
+ }
6570
+ if (payload.method && payload.id) {
6571
+ const handler = this._rpcHandlers.get(payload.method);
6572
+ if (!handler) {
6573
+ await this.send(msg.from, JSON.stringify({
6574
+ jsonrpc: "2.0",
6575
+ id: payload.id,
6576
+ error: { code: -32601, message: `Method not found: ${payload.method}` }
6577
+ }), { messageType: "rpc-response", threadId: payload.id });
6578
+ return;
6579
+ }
6580
+ try {
6581
+ const result = await handler(payload.params || {}, msg.from);
6582
+ await this.send(msg.from, JSON.stringify({
6583
+ jsonrpc: "2.0",
6584
+ id: payload.id,
6585
+ result
6586
+ }), { messageType: "rpc-response", threadId: payload.id });
6587
+ } catch (err) {
6588
+ await this.send(msg.from, JSON.stringify({
6589
+ jsonrpc: "2.0",
6590
+ id: payload.id,
6591
+ error: { code: -32e3, message: err.message || "Handler error" }
6592
+ }), { messageType: "rpc-response", threadId: payload.id });
6593
+ }
6594
+ }
6595
+ } catch {
6596
+ }
6597
+ }, { interval: 500, adaptive: false, heartbeat: false });
6598
+ }
6599
+ // ═══════════════════════════════════════════════════════════════════════════
6600
+ // P2P DIRECT MODE — Bypass Relay When Possible
6601
+ // ═══════════════════════════════════════════════════════════════════════════
6602
+ /**
6603
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
6604
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
6605
+ *
6606
+ * Falls back to relay-based send if direct delivery fails.
6607
+ *
6608
+ * @example
6609
+ * ```ts
6610
+ * // Try direct first, fall back to relay
6611
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
6612
+ * console.log(result.direct); // true if delivered directly, false if via relay
6613
+ * ```
6614
+ */
6615
+ async sendDirect(recipientDid, message, options = {}) {
6616
+ try {
6617
+ const profile = await this.getIdentity(recipientDid);
6618
+ if (profile) {
6619
+ const webhookRes = await this._timedFetch(
6620
+ `${this.baseUrl}/v1/agent/identity/${recipientDid}`,
6621
+ { headers: { "X-Agent-Key": this.apiKey } }
6622
+ );
6623
+ if (webhookRes.ok) {
6624
+ const data = await webhookRes.json();
6625
+ const webhookUrl = data.webhook_url;
6626
+ if (webhookUrl) {
6627
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
6628
+ const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
6629
+ const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
6630
+ const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
6631
+ const envelope = JSON.stringify({
6632
+ from: this.did,
6633
+ to: recipientDid,
6634
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
6635
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
6636
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6637
+ message_type: options.messageType || "text",
6638
+ thread_id: options.threadId
6639
+ });
6640
+ const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
6641
+ const directRes = await this._timedFetch(webhookUrl, {
6642
+ method: "POST",
6643
+ headers: {
6644
+ "Content-Type": "application/json",
6645
+ "X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
6646
+ "X-Voidly-Sender": this.did
6647
+ },
6648
+ body: envelope
6649
+ });
6650
+ if (directRes.ok) {
6651
+ const now = /* @__PURE__ */ new Date();
6652
+ return {
6653
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
6654
+ from: this.did,
6655
+ to: recipientDid,
6656
+ timestamp: now.toISOString(),
6657
+ expiresAt: new Date(now.getTime() + 864e5).toISOString(),
6658
+ encrypted: true,
6659
+ clientSide: true,
6660
+ direct: true
6661
+ };
6662
+ }
6663
+ }
6664
+ }
6665
+ }
6666
+ } catch {
6667
+ }
6668
+ const result = await this.send(recipientDid, message, options);
6669
+ return { ...result, direct: false };
6670
+ }
6671
+ // ═══════════════════════════════════════════════════════════════════════════
6672
+ // COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
6673
+ // ═══════════════════════════════════════════════════════════════════════════
6674
+ /**
6675
+ * Enable cover traffic — sends encrypted noise at random intervals.
6676
+ * Makes real messages indistinguishable from cover traffic for any observer
6677
+ * monitoring message timing and frequency.
6678
+ *
6679
+ * Cover messages are encrypted and padded identically to real messages.
6680
+ * The relay cannot distinguish them from real traffic.
6681
+ *
6682
+ * @example
6683
+ * ```ts
6684
+ * // Send noise every ~30s (randomized ±50%)
6685
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
6686
+ *
6687
+ * // Stop cover traffic
6688
+ * agent.disableCoverTraffic();
6689
+ * ```
6690
+ */
6691
+ enableCoverTraffic(options = {}) {
6692
+ this.disableCoverTraffic();
6693
+ const baseInterval = options.intervalMs || 3e4;
6694
+ const sendNoise = async () => {
6695
+ try {
6696
+ const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
6697
+ await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
6698
+ messageType: "ping",
6699
+ // use 'ping' type — indistinguishable in encrypted payload
6700
+ ttl: 60
6701
+ // short TTL — noise auto-expires
6702
+ });
6703
+ } catch {
6704
+ }
6705
+ };
6706
+ const scheduleNext = () => {
6707
+ const jitter = baseInterval * (0.5 + Math.random());
6708
+ this._coverTrafficTimer = setTimeout(async () => {
6709
+ await sendNoise();
6710
+ if (this._coverTrafficTimer !== null) scheduleNext();
6711
+ }, jitter);
6712
+ };
6713
+ scheduleNext();
6714
+ }
6715
+ /**
6716
+ * Disable cover traffic.
6717
+ */
6718
+ disableCoverTraffic() {
6719
+ if (this._coverTrafficTimer !== null) {
6720
+ clearTimeout(this._coverTrafficTimer);
6721
+ this._coverTrafficTimer = null;
6722
+ }
6723
+ }
6724
+ // ═══════════════════════════════════════════════════════════════════════════
6725
+ // RESILIENT OPERATIONS — Fallback for All Operations
6726
+ // ═══════════════════════════════════════════════════════════════════════════
6727
+ /**
6728
+ * Fetch from primary relay with fallback to alternate relays.
6729
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
6730
+ * @internal
6731
+ */
6732
+ async _resilientFetch(path, init) {
6733
+ const relays = [this.baseUrl, ...this.fallbackRelays];
6734
+ let lastError = null;
6735
+ for (const relay of relays) {
6736
+ try {
6737
+ const res = await this._timedFetch(`${relay}${path}`, init);
6738
+ if (res.ok || res.status >= 400 && res.status < 500) return res;
6739
+ } catch (err) {
6740
+ lastError = err instanceof Error ? err : new Error(String(err));
6741
+ }
6742
+ }
6743
+ throw lastError || new Error("All relays failed");
6183
6744
  }
6184
6745
  // ═══════════════════════════════════════════════════════════════════════════
6185
6746
  // CONVERSATIONS — Thread Management
@@ -6318,13 +6879,11 @@ var VoidlyAgent = class _VoidlyAgent {
6318
6879
  return {
6319
6880
  relayCanSee: [
6320
6881
  "Your DID (public identifier)",
6321
- ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6882
+ "Recipient DIDs (relay needs them for routing)",
6322
6883
  ...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
6323
- "Message types (text, task-request, etc.)",
6324
- "Thread structure (which messages are replies)",
6884
+ ...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
6325
6885
  "Channel membership (but NOT channel message content with client-side encryption)",
6326
6886
  "Capability registrations",
6327
- "Online/offline status",
6328
6887
  "Approximate message size (even with padding, bounded to power-of-2)"
6329
6888
  ],
6330
6889
  relayCannotSee: [
@@ -6334,7 +6893,11 @@ var VoidlyAgent = class _VoidlyAgent {
6334
6893
  "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6335
6894
  "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6336
6895
  "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6337
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6896
+ ...this.sealedSender ? [
6897
+ 'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
6898
+ "Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
6899
+ "Message count (not incremented for sealed senders)"
6900
+ ] : [],
6338
6901
  ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
6339
6902
  ],
6340
6903
  protections: [
@@ -6352,22 +6915,34 @@ var VoidlyAgent = class _VoidlyAgent {
6352
6915
  "Request timeouts (AbortController on all HTTP, configurable)",
6353
6916
  "Request validation (fromCredentials validates key sizes and format)",
6354
6917
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
6355
- ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
6918
+ ...this.sealedSender ? [
6919
+ "Sealed sender (relay cannot see who sent a message)",
6920
+ "Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
6921
+ ] : [],
6356
6922
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6357
- ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6923
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6358
6924
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6925
+ ...this._transportPrefs.includes("sse") ? ["SSE streaming transport (real-time push delivery from relay)"] : [],
6359
6926
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6927
+ ...this._persistMode !== "memory" ? [`Ratchet state auto-persistence (${this._persistMode} backend \u2014 survives process restart)`] : [],
6928
+ ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6929
+ "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6930
+ "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
6931
+ "Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
6360
6932
  "Auto-retry with exponential backoff",
6361
6933
  "Offline message queue",
6362
6934
  "did:key interoperability (W3C standard DID format)"
6363
6935
  ],
6364
6936
  gaps: [
6365
6937
  ...!this.postQuantum || !this.mlkemPublicKey ? ["No post-quantum protection \u2014 enable postQuantum option and re-register"] : [],
6366
- "Single relay architecture (no onion routing, no mix network \u2014 mitigated by multi-relay fallback)",
6367
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6938
+ ...this.sealedSender ? ["Relay sees to_did (needed for routing) but NOT from_did, thread_id, or message_type"] : ["Relay sees from_did, to_did, thread_id, message_type in cleartext \u2014 enable sealedSender to strip metadata"],
6939
+ "Relay sees channel membership, task delegation, trust scores (social graph)",
6940
+ ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6941
+ ...this._persistMode === "memory" ? ["Ratchet state is in-memory (lost on process restart \u2014 use persist option or exportCredentials)"] : [],
6368
6942
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6369
6943
  ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6370
- ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : []
6944
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
6945
+ ...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
6371
6946
  ]
6372
6947
  };
6373
6948
  }