@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.mjs CHANGED
@@ -3972,19 +3972,29 @@ async function ratchetStep(chainKey) {
3972
3972
  );
3973
3973
  return { nextChainKey, messageKey };
3974
3974
  }
3975
- function sealEnvelope(senderDid, plaintext) {
3976
- return JSON.stringify({
3977
- v: 2,
3975
+ function sealEnvelope(senderDid, plaintext, meta) {
3976
+ const obj = {
3977
+ v: 3,
3978
3978
  from: senderDid,
3979
3979
  msg: plaintext,
3980
3980
  ts: (/* @__PURE__ */ new Date()).toISOString()
3981
- });
3981
+ };
3982
+ if (meta?.contentType && meta.contentType !== "text/plain") obj.ct = meta.contentType;
3983
+ if (meta?.messageType && meta.messageType !== "text") obj.mt = meta.messageType;
3984
+ if (meta?.threadId) obj.tid = meta.threadId;
3985
+ if (meta?.replyTo) obj.rto = meta.replyTo;
3986
+ return JSON.stringify(obj);
3982
3987
  }
3983
3988
  function unsealEnvelope(plaintext) {
3984
3989
  try {
3985
3990
  const parsed = JSON.parse(plaintext);
3986
- if (parsed.v === 2 && parsed.from && parsed.msg) {
3987
- return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
3991
+ if ((parsed.v === 2 || parsed.v === 3) && parsed.from && parsed.msg) {
3992
+ const result = { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
3993
+ if (parsed.ct) result.contentType = parsed.ct;
3994
+ if (parsed.mt) result.messageType = parsed.mt;
3995
+ if (parsed.tid) result.threadId = parsed.tid;
3996
+ if (parsed.rto) result.replyTo = parsed.rto;
3997
+ return result;
3988
3998
  }
3989
3999
  return null;
3990
4000
  } catch {
@@ -4062,6 +4072,17 @@ var VoidlyAgent = class _VoidlyAgent {
4062
4072
  this._identityCache = /* @__PURE__ */ new Map();
4063
4073
  this._seenMessageIds = /* @__PURE__ */ new Set();
4064
4074
  this._decryptFailCount = 0;
4075
+ // RPC handlers: method → handler function
4076
+ this._rpcHandlers = /* @__PURE__ */ new Map();
4077
+ // RPC pending responses: rpc_id → { resolve, reject, timer }
4078
+ this._rpcPending = /* @__PURE__ */ new Map();
4079
+ // Cover traffic state
4080
+ this._coverTrafficTimer = null;
4081
+ // RPC listener handle (started on first onInvoke)
4082
+ this._rpcListener = null;
4083
+ // Persistence (v3.2)
4084
+ this._persistMode = "memory";
4085
+ this._persistKey = null;
4065
4086
  this.did = identity.did;
4066
4087
  this.apiKey = identity.apiKey;
4067
4088
  this.signingKeyPair = identity.signingKeyPair;
@@ -4081,6 +4102,11 @@ var VoidlyAgent = class _VoidlyAgent {
4081
4102
  this.longPoll = config?.longPoll !== false;
4082
4103
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4083
4104
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4105
+ this._persistMode = config?.persist || "memory";
4106
+ this._onPersist = config?.onPersist;
4107
+ this._onLoad = config?.onLoad;
4108
+ this._persistPath = config?.persistPath;
4109
+ this._transportPrefs = config?.transport || ["sse", "long-poll"];
4084
4110
  }
4085
4111
  // ─── Factory Methods ────────────────────────────────────────────────────────
4086
4112
  /**
@@ -4285,6 +4311,186 @@ var VoidlyAgent = class _VoidlyAgent {
4285
4311
  } : {}
4286
4312
  };
4287
4313
  }
4314
+ // ─── Ratchet Persistence (v3.2) ────────────────────────────────────────────
4315
+ /** Derive persistence encryption key from signing secret */
4316
+ _derivePersistKey() {
4317
+ if (this._persistKey) return this._persistKey;
4318
+ const salt = (0, import_tweetnacl_util.decodeUTF8)("voidly-persist-v1");
4319
+ const input = new Uint8Array(this.signingKeyPair.secretKey.length + salt.length);
4320
+ input.set(this.signingKeyPair.secretKey, 0);
4321
+ input.set(salt, this.signingKeyPair.secretKey.length);
4322
+ this._persistKey = import_tweetnacl.default.hash(input).slice(0, 32);
4323
+ return this._persistKey;
4324
+ }
4325
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
4326
+ async _persistRatchetState() {
4327
+ if (this._persistMode === "memory") return;
4328
+ try {
4329
+ const creds = this.exportCredentials();
4330
+ const data = JSON.stringify(creds.ratchetStates || {});
4331
+ const key = this._derivePersistKey();
4332
+ const nonce = import_tweetnacl.default.randomBytes(24);
4333
+ const encrypted = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(data), nonce, key);
4334
+ const blob = JSON.stringify({ n: (0, import_tweetnacl_util.encodeBase64)(nonce), c: (0, import_tweetnacl_util.encodeBase64)(encrypted), v: 1 });
4335
+ switch (this._persistMode) {
4336
+ case "localStorage":
4337
+ if (typeof localStorage !== "undefined") {
4338
+ localStorage.setItem(`voidly-ratchet-${this.did}`, blob);
4339
+ }
4340
+ break;
4341
+ case "indexedDB":
4342
+ await this._idbPut(blob);
4343
+ break;
4344
+ case "file":
4345
+ if (this._persistPath) {
4346
+ const fs = await import("fs/promises");
4347
+ await fs.writeFile(this._persistPath, blob, "utf-8");
4348
+ }
4349
+ break;
4350
+ case "relay":
4351
+ await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4352
+ method: "PUT",
4353
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
4354
+ body: JSON.stringify({ value: blob })
4355
+ }).catch(() => {
4356
+ });
4357
+ break;
4358
+ case "custom":
4359
+ if (this._onPersist) await this._onPersist(blob);
4360
+ break;
4361
+ }
4362
+ } catch {
4363
+ }
4364
+ }
4365
+ /** Load persisted ratchet state and restore into memory */
4366
+ async _loadPersistedRatchetState() {
4367
+ if (this._persistMode === "memory") return;
4368
+ let blob = null;
4369
+ try {
4370
+ switch (this._persistMode) {
4371
+ case "localStorage":
4372
+ if (typeof localStorage !== "undefined") {
4373
+ blob = localStorage.getItem(`voidly-ratchet-${this.did}`);
4374
+ }
4375
+ break;
4376
+ case "indexedDB":
4377
+ blob = await this._idbGet();
4378
+ break;
4379
+ case "file":
4380
+ if (this._persistPath) {
4381
+ try {
4382
+ const fs = await import("fs/promises");
4383
+ blob = await fs.readFile(this._persistPath, "utf-8");
4384
+ } catch {
4385
+ }
4386
+ }
4387
+ break;
4388
+ case "relay":
4389
+ try {
4390
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4391
+ headers: { "X-Agent-Key": this.apiKey }
4392
+ });
4393
+ if (res.ok) {
4394
+ const data = await res.json();
4395
+ blob = data.value;
4396
+ }
4397
+ } catch {
4398
+ }
4399
+ break;
4400
+ case "custom":
4401
+ if (this._onLoad) blob = await this._onLoad();
4402
+ break;
4403
+ }
4404
+ } catch {
4405
+ return;
4406
+ }
4407
+ if (!blob) return;
4408
+ try {
4409
+ const { n, c } = JSON.parse(blob);
4410
+ const key = this._derivePersistKey();
4411
+ const decrypted = import_tweetnacl.default.secretbox.open((0, import_tweetnacl_util.decodeBase64)(c), (0, import_tweetnacl_util.decodeBase64)(n), key);
4412
+ if (!decrypted) return;
4413
+ const states = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(decrypted));
4414
+ for (const [pairId, rs] of Object.entries(states)) {
4415
+ if (this._ratchetStates.has(pairId)) continue;
4416
+ const state = {
4417
+ sendChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey),
4418
+ sendStep: rs.sendStep,
4419
+ recvChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey),
4420
+ recvStep: rs.recvStep,
4421
+ skippedKeys: /* @__PURE__ */ new Map()
4422
+ };
4423
+ if (rs.rootKey) state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4424
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4425
+ state.dhSendKeyPair = {
4426
+ secretKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey),
4427
+ publicKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey)
4428
+ };
4429
+ }
4430
+ if (rs.dhRecvPubKey) state.dhRecvPubKey = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4431
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4432
+ if (state.sendChainKey.length === 32 && state.recvChainKey.length === 32) {
4433
+ this._ratchetStates.set(pairId, state);
4434
+ }
4435
+ }
4436
+ } catch {
4437
+ }
4438
+ }
4439
+ /** IndexedDB put helper (browser only) */
4440
+ async _idbPut(blob) {
4441
+ if (typeof indexedDB === "undefined") return;
4442
+ return new Promise((resolve, reject) => {
4443
+ const req = indexedDB.open("voidly-agent", 1);
4444
+ req.onupgradeneeded = () => {
4445
+ const db = req.result;
4446
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4447
+ };
4448
+ req.onsuccess = () => {
4449
+ const tx = req.result.transaction("ratchet", "readwrite");
4450
+ tx.objectStore("ratchet").put(blob, this.did);
4451
+ tx.oncomplete = () => resolve();
4452
+ tx.onerror = () => reject(tx.error);
4453
+ };
4454
+ req.onerror = () => reject(req.error);
4455
+ });
4456
+ }
4457
+ /** IndexedDB get helper (browser only) */
4458
+ async _idbGet() {
4459
+ if (typeof indexedDB === "undefined") return null;
4460
+ return new Promise((resolve, reject) => {
4461
+ const req = indexedDB.open("voidly-agent", 1);
4462
+ req.onupgradeneeded = () => {
4463
+ const db = req.result;
4464
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4465
+ };
4466
+ req.onsuccess = () => {
4467
+ const tx = req.result.transaction("ratchet", "readonly");
4468
+ const getReq = tx.objectStore("ratchet").get(this.did);
4469
+ getReq.onsuccess = () => resolve(getReq.result || null);
4470
+ getReq.onerror = () => reject(getReq.error);
4471
+ };
4472
+ req.onerror = () => reject(req.error);
4473
+ });
4474
+ }
4475
+ /**
4476
+ * Force-persist current ratchet state.
4477
+ * Useful to call before process exit to ensure state is saved.
4478
+ */
4479
+ async flushRatchetState() {
4480
+ const origMode = this._persistMode;
4481
+ if (origMode === "memory") this._persistMode = "file";
4482
+ await this._persistRatchetState();
4483
+ this._persistMode = origMode;
4484
+ }
4485
+ /**
4486
+ * Restore an agent from credentials with async persistence loading.
4487
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
4488
+ */
4489
+ static async fromCredentialsAsync(creds, config) {
4490
+ const agent = _VoidlyAgent.fromCredentials(creds, config);
4491
+ await agent._loadPersistedRatchetState();
4492
+ return agent;
4493
+ }
4288
4494
  /**
4289
4495
  * Get the number of messages that failed to decrypt.
4290
4496
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -4332,7 +4538,12 @@ var VoidlyAgent = class _VoidlyAgent {
4332
4538
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
4333
4539
  let plaintext = message;
4334
4540
  if (useSealed) {
4335
- plaintext = sealEnvelope(this.did, message);
4541
+ plaintext = sealEnvelope(this.did, message, {
4542
+ contentType: options.contentType,
4543
+ messageType: options.messageType,
4544
+ threadId: options.threadId,
4545
+ replyTo: options.replyTo
4546
+ });
4336
4547
  }
4337
4548
  let contentBytes;
4338
4549
  if (usePadding) {
@@ -4447,12 +4658,16 @@ var VoidlyAgent = class _VoidlyAgent {
4447
4658
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
4448
4659
  signature: (0, import_tweetnacl_util.encodeBase64)(signature),
4449
4660
  envelope: envelopeData,
4450
- content_type: options.contentType || "text/plain",
4451
- message_type: options.messageType || "text",
4452
- thread_id: options.threadId,
4453
- reply_to: options.replyTo,
4454
4661
  ttl: options.ttl
4455
4662
  };
4663
+ if (!useSealed) {
4664
+ payload.content_type = options.contentType || "text/plain";
4665
+ payload.message_type = options.messageType || "text";
4666
+ payload.thread_id = options.threadId;
4667
+ payload.reply_to = options.replyTo;
4668
+ }
4669
+ this._persistRatchetState().catch(() => {
4670
+ });
4456
4671
  const relays = [this.baseUrl, ...this.fallbackRelays];
4457
4672
  let lastError = null;
4458
4673
  for (const relay of relays) {
@@ -4503,7 +4718,7 @@ var VoidlyAgent = class _VoidlyAgent {
4503
4718
  if (options.contentType) params.set("content_type", options.contentType);
4504
4719
  if (options.messageType) params.set("message_type", options.messageType);
4505
4720
  if (options.unreadOnly) params.set("unread", "true");
4506
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/raw?${params}`, {
4721
+ const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
4507
4722
  headers: { "X-Agent-Key": this.apiKey }
4508
4723
  });
4509
4724
  if (!res.ok) {
@@ -4511,11 +4726,28 @@ var VoidlyAgent = class _VoidlyAgent {
4511
4726
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4512
4727
  }
4513
4728
  const data = await res.json();
4729
+ return this._decryptMessages(data.messages);
4730
+ }
4731
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4732
+ async _decryptMessages(rawMessages) {
4514
4733
  const decrypted = [];
4515
- for (const msg of data.messages) {
4734
+ for (const msg of rawMessages) {
4516
4735
  try {
4517
4736
  if (this._seenMessageIds.has(msg.id)) continue;
4518
- const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4737
+ let senderEncPub;
4738
+ let senderSignPubBytes = null;
4739
+ if (msg.sender_encryption_key) {
4740
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4741
+ if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4742
+ } else if (msg.envelope) {
4743
+ const env = JSON.parse(msg.envelope);
4744
+ const senderProfile = await this.getIdentity(env.from);
4745
+ if (!senderProfile) continue;
4746
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
4747
+ if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
4748
+ } else {
4749
+ continue;
4750
+ }
4519
4751
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
4520
4752
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
4521
4753
  let rawPlaintext = null;
@@ -4523,7 +4755,6 @@ var VoidlyAgent = class _VoidlyAgent {
4523
4755
  let envelopePqCiphertext = null;
4524
4756
  let envelopeDhRatchetKey = null;
4525
4757
  let envelopePn = 0;
4526
- let envelopeDeniable = false;
4527
4758
  if (msg.envelope) {
4528
4759
  try {
4529
4760
  const env = JSON.parse(msg.envelope);
@@ -4683,11 +4914,19 @@ var VoidlyAgent = class _VoidlyAgent {
4683
4914
  }
4684
4915
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
4685
4916
  let senderDid = msg.from;
4917
+ let innerContentType;
4918
+ let innerMessageType;
4919
+ let innerThreadId;
4920
+ let innerReplyTo;
4686
4921
  if (wasSealed || !proto) {
4687
4922
  const unsealed = unsealEnvelope(content);
4688
4923
  if (unsealed) {
4689
4924
  content = unsealed.msg;
4690
4925
  senderDid = unsealed.from;
4926
+ innerContentType = unsealed.contentType;
4927
+ innerMessageType = unsealed.messageType;
4928
+ innerThreadId = unsealed.threadId;
4929
+ innerReplyTo = unsealed.replyTo;
4691
4930
  }
4692
4931
  }
4693
4932
  let signatureValid = false;
@@ -4708,12 +4947,11 @@ var VoidlyAgent = class _VoidlyAgent {
4708
4947
  for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4709
4948
  signatureValid = diff === 0;
4710
4949
  }
4711
- } else {
4712
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4950
+ } else if (senderSignPubBytes) {
4713
4951
  signatureValid = import_tweetnacl.default.sign.detached.verify(
4714
4952
  (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4715
4953
  signatureBytes,
4716
- senderSignPub
4954
+ senderSignPubBytes
4717
4955
  );
4718
4956
  }
4719
4957
  } catch {
@@ -4733,10 +4971,11 @@ var VoidlyAgent = class _VoidlyAgent {
4733
4971
  from: senderDid,
4734
4972
  to: msg.to,
4735
4973
  content,
4736
- contentType: msg.content_type,
4737
- messageType: msg.message_type || "text",
4738
- threadId: msg.thread_id,
4739
- replyTo: msg.reply_to,
4974
+ // v3: prefer metadata from inside ciphertext (relay can't see it)
4975
+ contentType: innerContentType || msg.content_type || "text/plain",
4976
+ messageType: innerMessageType || msg.message_type || "text",
4977
+ threadId: innerThreadId || msg.thread_id || null,
4978
+ replyTo: innerReplyTo || msg.reply_to || null,
4740
4979
  signatureValid,
4741
4980
  timestamp: msg.timestamp,
4742
4981
  expiresAt: msg.expires_at
@@ -4745,6 +4984,10 @@ var VoidlyAgent = class _VoidlyAgent {
4745
4984
  this._decryptFailCount++;
4746
4985
  }
4747
4986
  }
4987
+ if (decrypted.length > 0) {
4988
+ this._persistRatchetState().catch(() => {
4989
+ });
4990
+ }
4748
4991
  return decrypted;
4749
4992
  }
4750
4993
  // ─── Message Management ─────────────────────────────────────────────────────
@@ -4797,7 +5040,7 @@ var VoidlyAgent = class _VoidlyAgent {
4797
5040
  if (cached && Date.now() - cached.cachedAt < 3e5) {
4798
5041
  return cached.profile;
4799
5042
  }
4800
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/identity/${did}`);
5043
+ const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
4801
5044
  if (!res.ok) return null;
4802
5045
  const profile = await res.json();
4803
5046
  this._identityCache.set(did, { profile, cachedAt: Date.now() });
@@ -4815,7 +5058,7 @@ var VoidlyAgent = class _VoidlyAgent {
4815
5058
  if (options.query) params.set("query", options.query);
4816
5059
  if (options.capability) params.set("capability", options.capability);
4817
5060
  if (options.limit) params.set("limit", String(options.limit));
4818
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/discover?${params}`);
5061
+ const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
4819
5062
  if (!res.ok) return [];
4820
5063
  const data = await res.json();
4821
5064
  return data.agents;
@@ -6033,6 +6276,84 @@ var VoidlyAgent = class _VoidlyAgent {
6033
6276
  this.ping().catch(() => {
6034
6277
  });
6035
6278
  }
6279
+ const deliverMessages = async (messages) => {
6280
+ for (const msg of messages) {
6281
+ try {
6282
+ await onMessage(msg);
6283
+ if (autoMarkRead) {
6284
+ await this.markRead(msg.id).catch(() => {
6285
+ });
6286
+ }
6287
+ } catch (err) {
6288
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6289
+ }
6290
+ }
6291
+ if (messages.length > 0) {
6292
+ lastSeen = messages[messages.length - 1].timestamp;
6293
+ }
6294
+ };
6295
+ const startSSE = async () => {
6296
+ try {
6297
+ const params = new URLSearchParams();
6298
+ if (lastSeen) params.set("since", lastSeen);
6299
+ if (options.from) params.set("from", options.from);
6300
+ const sseUrl = `${this.baseUrl}/v1/agent/receive/sse?${params}`;
6301
+ const res = await this._timedFetch(sseUrl, {
6302
+ headers: { "X-Agent-Key": this.apiKey }
6303
+ });
6304
+ if (!res.ok || !res.body) return false;
6305
+ const reader = res.body.getReader();
6306
+ const decoder = new TextDecoder();
6307
+ let buffer = "";
6308
+ while (active && !options.signal?.aborted) {
6309
+ const { done: streamDone, value } = await reader.read();
6310
+ if (streamDone) break;
6311
+ buffer += decoder.decode(value, { stream: true });
6312
+ const lines = buffer.split("\n");
6313
+ buffer = lines.pop() || "";
6314
+ let eventType = "";
6315
+ let dataStr = "";
6316
+ for (const line of lines) {
6317
+ if (line.startsWith("event: ")) {
6318
+ eventType = line.slice(7).trim();
6319
+ } else if (line.startsWith("data: ")) {
6320
+ dataStr = line.slice(6);
6321
+ } else if (line === "" && dataStr) {
6322
+ if (eventType === "message" && dataStr) {
6323
+ try {
6324
+ const rawMsg = JSON.parse(dataStr);
6325
+ const decrypted = await this._decryptMessages([rawMsg]);
6326
+ if (decrypted.length > 0) {
6327
+ consecutiveEmpty = 0;
6328
+ await deliverMessages(decrypted);
6329
+ }
6330
+ } catch {
6331
+ }
6332
+ } else if (eventType === "reconnect") {
6333
+ break;
6334
+ }
6335
+ eventType = "";
6336
+ dataStr = "";
6337
+ }
6338
+ }
6339
+ }
6340
+ reader.releaseLock();
6341
+ return true;
6342
+ } catch {
6343
+ return false;
6344
+ }
6345
+ };
6346
+ const sseLoop = async () => {
6347
+ while (active && !options.signal?.aborted) {
6348
+ const ok = await startSSE();
6349
+ if (!active || options.signal?.aborted) break;
6350
+ if (!ok) {
6351
+ poll();
6352
+ return;
6353
+ }
6354
+ await new Promise((r) => setTimeout(r, 500));
6355
+ }
6356
+ };
6036
6357
  const useLongPoll = this.longPoll;
6037
6358
  const poll = async () => {
6038
6359
  if (!active || options.signal?.aborted) {
@@ -6040,62 +6361,18 @@ var VoidlyAgent = class _VoidlyAgent {
6040
6361
  return;
6041
6362
  }
6042
6363
  try {
6043
- let messages;
6044
- if (useLongPoll) {
6045
- const params = new URLSearchParams({ timeout: "25" });
6046
- if (lastSeen) params.set("since", lastSeen);
6047
- if (options.from) params.set("from", options.from);
6048
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6049
- headers: { "X-Agent-Key": this.apiKey }
6050
- });
6051
- if (res.ok) {
6052
- const data = await res.json();
6053
- messages = [];
6054
- for (const raw of data.messages) {
6055
- try {
6056
- if (this._seenMessageIds.has(raw.id)) continue;
6057
- this._seenMessageIds.add(raw.id);
6058
- } catch {
6059
- }
6060
- }
6061
- if (data.messages.length > 0) {
6062
- messages = await this.receive({
6063
- since: lastSeen,
6064
- from: options.from,
6065
- threadId: options.threadId,
6066
- messageType: options.messageType,
6067
- unreadOnly,
6068
- limit: 50
6069
- });
6070
- }
6071
- } else {
6072
- messages = [];
6073
- }
6074
- } else {
6075
- messages = await this.receive({
6076
- since: lastSeen,
6077
- from: options.from,
6078
- threadId: options.threadId,
6079
- messageType: options.messageType,
6080
- unreadOnly,
6081
- limit: 50
6082
- });
6083
- }
6364
+ const messages = await this.receive({
6365
+ since: lastSeen,
6366
+ from: options.from,
6367
+ threadId: options.threadId,
6368
+ messageType: options.messageType,
6369
+ unreadOnly,
6370
+ limit: 50
6371
+ });
6084
6372
  if (messages.length > 0) {
6085
6373
  consecutiveEmpty = 0;
6086
6374
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
6087
- for (const msg of messages) {
6088
- try {
6089
- await onMessage(msg);
6090
- if (autoMarkRead) {
6091
- await this.markRead(msg.id).catch(() => {
6092
- });
6093
- }
6094
- } catch (err) {
6095
- if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6096
- }
6097
- }
6098
- lastSeen = messages[messages.length - 1].timestamp;
6375
+ await deliverMessages(messages);
6099
6376
  } else {
6100
6377
  consecutiveEmpty++;
6101
6378
  if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
@@ -6110,7 +6387,12 @@ var VoidlyAgent = class _VoidlyAgent {
6110
6387
  timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
6111
6388
  }
6112
6389
  };
6113
- poll();
6390
+ const prefs = this._transportPrefs;
6391
+ if (prefs.includes("sse")) {
6392
+ sseLoop();
6393
+ } else {
6394
+ poll();
6395
+ }
6114
6396
  return handle;
6115
6397
  }
6116
6398
  /**
@@ -6169,6 +6451,285 @@ var VoidlyAgent = class _VoidlyAgent {
6169
6451
  listener.stop();
6170
6452
  }
6171
6453
  this._listeners.clear();
6454
+ if (this._rpcListener) {
6455
+ this._rpcListener.stop();
6456
+ this._rpcListener = null;
6457
+ }
6458
+ for (const [id, pending] of this._rpcPending) {
6459
+ clearTimeout(pending.timer);
6460
+ pending.reject(new Error("Agent stopped"));
6461
+ }
6462
+ this._rpcPending.clear();
6463
+ this.disableCoverTraffic();
6464
+ }
6465
+ // ═══════════════════════════════════════════════════════════════════════════
6466
+ // AGENT RPC — Synchronous Function Invocation Between Agents
6467
+ // ═══════════════════════════════════════════════════════════════════════════
6468
+ /**
6469
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
6470
+ * The remote agent must have registered a handler via `onInvoke()`.
6471
+ *
6472
+ * @example
6473
+ * ```ts
6474
+ * // Call a translator agent
6475
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
6476
+ * text: 'Hello, world!',
6477
+ * to: 'ja',
6478
+ * });
6479
+ * console.log(result.translation); // こんにちは
6480
+ *
6481
+ * // With timeout
6482
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
6483
+ * ```
6484
+ */
6485
+ async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
6486
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6487
+ return new Promise(async (resolve, reject) => {
6488
+ const timer = setTimeout(() => {
6489
+ this._rpcPending.delete(rpcId);
6490
+ reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
6491
+ }, timeoutMs);
6492
+ this._rpcPending.set(rpcId, { resolve, reject, timer });
6493
+ try {
6494
+ await this.send(targetDid, JSON.stringify({
6495
+ jsonrpc: "2.0",
6496
+ method,
6497
+ params,
6498
+ id: rpcId
6499
+ }), { messageType: "rpc-request", threadId: rpcId });
6500
+ } catch (err) {
6501
+ clearTimeout(timer);
6502
+ this._rpcPending.delete(rpcId);
6503
+ reject(err);
6504
+ }
6505
+ });
6506
+ }
6507
+ /**
6508
+ * Register a handler for incoming RPC invocations.
6509
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
6510
+ *
6511
+ * @example
6512
+ * ```ts
6513
+ * // Register a translation capability
6514
+ * agent.onInvoke('translate', async (params, callerDid) => {
6515
+ * const result = await myTranslateFunction(params.text, params.to);
6516
+ * return { translation: result };
6517
+ * });
6518
+ *
6519
+ * // Register a search capability
6520
+ * agent.onInvoke('search', async (params) => {
6521
+ * return { results: await searchDatabase(params.query) };
6522
+ * });
6523
+ * ```
6524
+ */
6525
+ onInvoke(method, handler) {
6526
+ this._rpcHandlers.set(method, handler);
6527
+ this._ensureRpcListener();
6528
+ }
6529
+ /**
6530
+ * Remove an RPC handler.
6531
+ */
6532
+ offInvoke(method) {
6533
+ this._rpcHandlers.delete(method);
6534
+ if (this._rpcHandlers.size === 0 && this._rpcListener) {
6535
+ this._rpcListener.stop();
6536
+ this._rpcListener = null;
6537
+ }
6538
+ }
6539
+ /** @internal Start listening for RPC requests and responses */
6540
+ _ensureRpcListener() {
6541
+ if (this._rpcListener) return;
6542
+ this._rpcListener = this.listen(async (msg) => {
6543
+ try {
6544
+ const payload = JSON.parse(msg.content);
6545
+ if (payload.jsonrpc !== "2.0") return;
6546
+ if (payload.id && (payload.result !== void 0 || payload.error)) {
6547
+ const pending = this._rpcPending.get(payload.id);
6548
+ if (pending) {
6549
+ clearTimeout(pending.timer);
6550
+ this._rpcPending.delete(payload.id);
6551
+ if (payload.error) {
6552
+ pending.reject(new Error(payload.error.message || "RPC error"));
6553
+ } else {
6554
+ pending.resolve(payload.result);
6555
+ }
6556
+ }
6557
+ return;
6558
+ }
6559
+ if (payload.method && payload.id) {
6560
+ const handler = this._rpcHandlers.get(payload.method);
6561
+ if (!handler) {
6562
+ await this.send(msg.from, JSON.stringify({
6563
+ jsonrpc: "2.0",
6564
+ id: payload.id,
6565
+ error: { code: -32601, message: `Method not found: ${payload.method}` }
6566
+ }), { messageType: "rpc-response", threadId: payload.id });
6567
+ return;
6568
+ }
6569
+ try {
6570
+ const result = await handler(payload.params || {}, msg.from);
6571
+ await this.send(msg.from, JSON.stringify({
6572
+ jsonrpc: "2.0",
6573
+ id: payload.id,
6574
+ result
6575
+ }), { messageType: "rpc-response", threadId: payload.id });
6576
+ } catch (err) {
6577
+ await this.send(msg.from, JSON.stringify({
6578
+ jsonrpc: "2.0",
6579
+ id: payload.id,
6580
+ error: { code: -32e3, message: err.message || "Handler error" }
6581
+ }), { messageType: "rpc-response", threadId: payload.id });
6582
+ }
6583
+ }
6584
+ } catch {
6585
+ }
6586
+ }, { interval: 500, adaptive: false, heartbeat: false });
6587
+ }
6588
+ // ═══════════════════════════════════════════════════════════════════════════
6589
+ // P2P DIRECT MODE — Bypass Relay When Possible
6590
+ // ═══════════════════════════════════════════════════════════════════════════
6591
+ /**
6592
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
6593
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
6594
+ *
6595
+ * Falls back to relay-based send if direct delivery fails.
6596
+ *
6597
+ * @example
6598
+ * ```ts
6599
+ * // Try direct first, fall back to relay
6600
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
6601
+ * console.log(result.direct); // true if delivered directly, false if via relay
6602
+ * ```
6603
+ */
6604
+ async sendDirect(recipientDid, message, options = {}) {
6605
+ try {
6606
+ const profile = await this.getIdentity(recipientDid);
6607
+ if (profile) {
6608
+ const webhookRes = await this._timedFetch(
6609
+ `${this.baseUrl}/v1/agent/identity/${recipientDid}`,
6610
+ { headers: { "X-Agent-Key": this.apiKey } }
6611
+ );
6612
+ if (webhookRes.ok) {
6613
+ const data = await webhookRes.json();
6614
+ const webhookUrl = data.webhook_url;
6615
+ if (webhookUrl) {
6616
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
6617
+ const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
6618
+ const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
6619
+ const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
6620
+ const envelope = JSON.stringify({
6621
+ from: this.did,
6622
+ to: recipientDid,
6623
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
6624
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
6625
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6626
+ message_type: options.messageType || "text",
6627
+ thread_id: options.threadId
6628
+ });
6629
+ const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
6630
+ const directRes = await this._timedFetch(webhookUrl, {
6631
+ method: "POST",
6632
+ headers: {
6633
+ "Content-Type": "application/json",
6634
+ "X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
6635
+ "X-Voidly-Sender": this.did
6636
+ },
6637
+ body: envelope
6638
+ });
6639
+ if (directRes.ok) {
6640
+ const now = /* @__PURE__ */ new Date();
6641
+ return {
6642
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
6643
+ from: this.did,
6644
+ to: recipientDid,
6645
+ timestamp: now.toISOString(),
6646
+ expiresAt: new Date(now.getTime() + 864e5).toISOString(),
6647
+ encrypted: true,
6648
+ clientSide: true,
6649
+ direct: true
6650
+ };
6651
+ }
6652
+ }
6653
+ }
6654
+ }
6655
+ } catch {
6656
+ }
6657
+ const result = await this.send(recipientDid, message, options);
6658
+ return { ...result, direct: false };
6659
+ }
6660
+ // ═══════════════════════════════════════════════════════════════════════════
6661
+ // COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
6662
+ // ═══════════════════════════════════════════════════════════════════════════
6663
+ /**
6664
+ * Enable cover traffic — sends encrypted noise at random intervals.
6665
+ * Makes real messages indistinguishable from cover traffic for any observer
6666
+ * monitoring message timing and frequency.
6667
+ *
6668
+ * Cover messages are encrypted and padded identically to real messages.
6669
+ * The relay cannot distinguish them from real traffic.
6670
+ *
6671
+ * @example
6672
+ * ```ts
6673
+ * // Send noise every ~30s (randomized ±50%)
6674
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
6675
+ *
6676
+ * // Stop cover traffic
6677
+ * agent.disableCoverTraffic();
6678
+ * ```
6679
+ */
6680
+ enableCoverTraffic(options = {}) {
6681
+ this.disableCoverTraffic();
6682
+ const baseInterval = options.intervalMs || 3e4;
6683
+ const sendNoise = async () => {
6684
+ try {
6685
+ const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
6686
+ await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
6687
+ messageType: "ping",
6688
+ // use 'ping' type — indistinguishable in encrypted payload
6689
+ ttl: 60
6690
+ // short TTL — noise auto-expires
6691
+ });
6692
+ } catch {
6693
+ }
6694
+ };
6695
+ const scheduleNext = () => {
6696
+ const jitter = baseInterval * (0.5 + Math.random());
6697
+ this._coverTrafficTimer = setTimeout(async () => {
6698
+ await sendNoise();
6699
+ if (this._coverTrafficTimer !== null) scheduleNext();
6700
+ }, jitter);
6701
+ };
6702
+ scheduleNext();
6703
+ }
6704
+ /**
6705
+ * Disable cover traffic.
6706
+ */
6707
+ disableCoverTraffic() {
6708
+ if (this._coverTrafficTimer !== null) {
6709
+ clearTimeout(this._coverTrafficTimer);
6710
+ this._coverTrafficTimer = null;
6711
+ }
6712
+ }
6713
+ // ═══════════════════════════════════════════════════════════════════════════
6714
+ // RESILIENT OPERATIONS — Fallback for All Operations
6715
+ // ═══════════════════════════════════════════════════════════════════════════
6716
+ /**
6717
+ * Fetch from primary relay with fallback to alternate relays.
6718
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
6719
+ * @internal
6720
+ */
6721
+ async _resilientFetch(path, init) {
6722
+ const relays = [this.baseUrl, ...this.fallbackRelays];
6723
+ let lastError = null;
6724
+ for (const relay of relays) {
6725
+ try {
6726
+ const res = await this._timedFetch(`${relay}${path}`, init);
6727
+ if (res.ok || res.status >= 400 && res.status < 500) return res;
6728
+ } catch (err) {
6729
+ lastError = err instanceof Error ? err : new Error(String(err));
6730
+ }
6731
+ }
6732
+ throw lastError || new Error("All relays failed");
6172
6733
  }
6173
6734
  // ═══════════════════════════════════════════════════════════════════════════
6174
6735
  // CONVERSATIONS — Thread Management
@@ -6307,13 +6868,11 @@ var VoidlyAgent = class _VoidlyAgent {
6307
6868
  return {
6308
6869
  relayCanSee: [
6309
6870
  "Your DID (public identifier)",
6310
- ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6871
+ "Recipient DIDs (relay needs them for routing)",
6311
6872
  ...this.jitterMs > 0 ? ["Approximate timing (jittered timestamps)"] : ["When you message (timestamps)"],
6312
- "Message types (text, task-request, etc.)",
6313
- "Thread structure (which messages are replies)",
6873
+ ...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
6314
6874
  "Channel membership (but NOT channel message content with client-side encryption)",
6315
6875
  "Capability registrations",
6316
- "Online/offline status",
6317
6876
  "Approximate message size (even with padding, bounded to power-of-2)"
6318
6877
  ],
6319
6878
  relayCannotSee: [
@@ -6323,7 +6882,11 @@ var VoidlyAgent = class _VoidlyAgent {
6323
6882
  "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6324
6883
  "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6325
6884
  "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6326
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6885
+ ...this.sealedSender ? [
6886
+ 'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
6887
+ "Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
6888
+ "Message count (not incremented for sealed senders)"
6889
+ ] : [],
6327
6890
  ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
6328
6891
  ],
6329
6892
  protections: [
@@ -6341,22 +6904,34 @@ var VoidlyAgent = class _VoidlyAgent {
6341
6904
  "Request timeouts (AbortController on all HTTP, configurable)",
6342
6905
  "Request validation (fromCredentials validates key sizes and format)",
6343
6906
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
6344
- ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
6907
+ ...this.sealedSender ? [
6908
+ "Sealed sender (relay cannot see who sent a message)",
6909
+ "Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
6910
+ ] : [],
6345
6911
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6346
- ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6912
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6347
6913
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6914
+ ...this._transportPrefs.includes("sse") ? ["SSE streaming transport (real-time push delivery from relay)"] : [],
6348
6915
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6916
+ ...this._persistMode !== "memory" ? [`Ratchet state auto-persistence (${this._persistMode} backend \u2014 survives process restart)`] : [],
6917
+ ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6918
+ "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6919
+ "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
6920
+ "Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
6349
6921
  "Auto-retry with exponential backoff",
6350
6922
  "Offline message queue",
6351
6923
  "did:key interoperability (W3C standard DID format)"
6352
6924
  ],
6353
6925
  gaps: [
6354
6926
  ...!this.postQuantum || !this.mlkemPublicKey ? ["No post-quantum protection \u2014 enable postQuantum option and re-register"] : [],
6355
- "Single relay architecture (no onion routing, no mix network \u2014 mitigated by multi-relay fallback)",
6356
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6927
+ ...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"],
6928
+ "Relay sees channel membership, task delegation, trust scores (social graph)",
6929
+ ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6930
+ ...this._persistMode === "memory" ? ["Ratchet state is in-memory (lost on process restart \u2014 use persist option or exportCredentials)"] : [],
6357
6931
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6358
6932
  ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6359
- ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : []
6933
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
6934
+ ...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
6360
6935
  ]
6361
6936
  };
6362
6937
  }