@voidly/agent-sdk 3.0.0 → 3.1.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.d.mts CHANGED
@@ -181,6 +181,10 @@ declare class VoidlyAgent {
181
181
  private _identityCache;
182
182
  private _seenMessageIds;
183
183
  private _decryptFailCount;
184
+ private _rpcHandlers;
185
+ private _rpcPending;
186
+ private _coverTrafficTimer;
187
+ private _rpcListener;
184
188
  private constructor();
185
189
  /**
186
190
  * Register a new agent on the Voidly relay.
@@ -996,6 +1000,100 @@ declare class VoidlyAgent {
996
1000
  * Stop all active listeners. Useful for clean shutdown.
997
1001
  */
998
1002
  stopAll(): void;
1003
+ /**
1004
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
1005
+ * The remote agent must have registered a handler via `onInvoke()`.
1006
+ *
1007
+ * @example
1008
+ * ```ts
1009
+ * // Call a translator agent
1010
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
1011
+ * text: 'Hello, world!',
1012
+ * to: 'ja',
1013
+ * });
1014
+ * console.log(result.translation); // こんにちは
1015
+ *
1016
+ * // With timeout
1017
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
1018
+ * ```
1019
+ */
1020
+ invoke(targetDid: string, method: string, params?: any, timeoutMs?: number): Promise<any>;
1021
+ /**
1022
+ * Register a handler for incoming RPC invocations.
1023
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
1024
+ *
1025
+ * @example
1026
+ * ```ts
1027
+ * // Register a translation capability
1028
+ * agent.onInvoke('translate', async (params, callerDid) => {
1029
+ * const result = await myTranslateFunction(params.text, params.to);
1030
+ * return { translation: result };
1031
+ * });
1032
+ *
1033
+ * // Register a search capability
1034
+ * agent.onInvoke('search', async (params) => {
1035
+ * return { results: await searchDatabase(params.query) };
1036
+ * });
1037
+ * ```
1038
+ */
1039
+ onInvoke(method: string, handler: (params: any, callerDid: string) => Promise<any>): void;
1040
+ /**
1041
+ * Remove an RPC handler.
1042
+ */
1043
+ offInvoke(method: string): void;
1044
+ /** @internal Start listening for RPC requests and responses */
1045
+ private _ensureRpcListener;
1046
+ /**
1047
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
1048
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
1049
+ *
1050
+ * Falls back to relay-based send if direct delivery fails.
1051
+ *
1052
+ * @example
1053
+ * ```ts
1054
+ * // Try direct first, fall back to relay
1055
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
1056
+ * console.log(result.direct); // true if delivered directly, false if via relay
1057
+ * ```
1058
+ */
1059
+ sendDirect(recipientDid: string, message: string, options?: {
1060
+ contentType?: string;
1061
+ messageType?: string;
1062
+ threadId?: string;
1063
+ ttl?: number;
1064
+ }): Promise<SendResult & {
1065
+ direct: boolean;
1066
+ }>;
1067
+ /**
1068
+ * Enable cover traffic — sends encrypted noise at random intervals.
1069
+ * Makes real messages indistinguishable from cover traffic for any observer
1070
+ * monitoring message timing and frequency.
1071
+ *
1072
+ * Cover messages are encrypted and padded identically to real messages.
1073
+ * The relay cannot distinguish them from real traffic.
1074
+ *
1075
+ * @example
1076
+ * ```ts
1077
+ * // Send noise every ~30s (randomized ±50%)
1078
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
1079
+ *
1080
+ * // Stop cover traffic
1081
+ * agent.disableCoverTraffic();
1082
+ * ```
1083
+ */
1084
+ enableCoverTraffic(options?: {
1085
+ intervalMs?: number;
1086
+ }): void;
1087
+ /**
1088
+ * Disable cover traffic.
1089
+ */
1090
+ disableCoverTraffic(): void;
1091
+ /**
1092
+ * Fetch from primary relay with fallback to alternate relays.
1093
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
1094
+ * @internal
1095
+ */
1096
+ private _resilientFetch;
999
1097
  /**
1000
1098
  * Start or resume a conversation with another agent.
1001
1099
  * Automatically manages thread IDs, message history, and reply chains.
package/dist/index.d.ts CHANGED
@@ -181,6 +181,10 @@ declare class VoidlyAgent {
181
181
  private _identityCache;
182
182
  private _seenMessageIds;
183
183
  private _decryptFailCount;
184
+ private _rpcHandlers;
185
+ private _rpcPending;
186
+ private _coverTrafficTimer;
187
+ private _rpcListener;
184
188
  private constructor();
185
189
  /**
186
190
  * Register a new agent on the Voidly relay.
@@ -996,6 +1000,100 @@ declare class VoidlyAgent {
996
1000
  * Stop all active listeners. Useful for clean shutdown.
997
1001
  */
998
1002
  stopAll(): void;
1003
+ /**
1004
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
1005
+ * The remote agent must have registered a handler via `onInvoke()`.
1006
+ *
1007
+ * @example
1008
+ * ```ts
1009
+ * // Call a translator agent
1010
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
1011
+ * text: 'Hello, world!',
1012
+ * to: 'ja',
1013
+ * });
1014
+ * console.log(result.translation); // こんにちは
1015
+ *
1016
+ * // With timeout
1017
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
1018
+ * ```
1019
+ */
1020
+ invoke(targetDid: string, method: string, params?: any, timeoutMs?: number): Promise<any>;
1021
+ /**
1022
+ * Register a handler for incoming RPC invocations.
1023
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
1024
+ *
1025
+ * @example
1026
+ * ```ts
1027
+ * // Register a translation capability
1028
+ * agent.onInvoke('translate', async (params, callerDid) => {
1029
+ * const result = await myTranslateFunction(params.text, params.to);
1030
+ * return { translation: result };
1031
+ * });
1032
+ *
1033
+ * // Register a search capability
1034
+ * agent.onInvoke('search', async (params) => {
1035
+ * return { results: await searchDatabase(params.query) };
1036
+ * });
1037
+ * ```
1038
+ */
1039
+ onInvoke(method: string, handler: (params: any, callerDid: string) => Promise<any>): void;
1040
+ /**
1041
+ * Remove an RPC handler.
1042
+ */
1043
+ offInvoke(method: string): void;
1044
+ /** @internal Start listening for RPC requests and responses */
1045
+ private _ensureRpcListener;
1046
+ /**
1047
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
1048
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
1049
+ *
1050
+ * Falls back to relay-based send if direct delivery fails.
1051
+ *
1052
+ * @example
1053
+ * ```ts
1054
+ * // Try direct first, fall back to relay
1055
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
1056
+ * console.log(result.direct); // true if delivered directly, false if via relay
1057
+ * ```
1058
+ */
1059
+ sendDirect(recipientDid: string, message: string, options?: {
1060
+ contentType?: string;
1061
+ messageType?: string;
1062
+ threadId?: string;
1063
+ ttl?: number;
1064
+ }): Promise<SendResult & {
1065
+ direct: boolean;
1066
+ }>;
1067
+ /**
1068
+ * Enable cover traffic — sends encrypted noise at random intervals.
1069
+ * Makes real messages indistinguishable from cover traffic for any observer
1070
+ * monitoring message timing and frequency.
1071
+ *
1072
+ * Cover messages are encrypted and padded identically to real messages.
1073
+ * The relay cannot distinguish them from real traffic.
1074
+ *
1075
+ * @example
1076
+ * ```ts
1077
+ * // Send noise every ~30s (randomized ±50%)
1078
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
1079
+ *
1080
+ * // Stop cover traffic
1081
+ * agent.disableCoverTraffic();
1082
+ * ```
1083
+ */
1084
+ enableCoverTraffic(options?: {
1085
+ intervalMs?: number;
1086
+ }): void;
1087
+ /**
1088
+ * Disable cover traffic.
1089
+ */
1090
+ disableCoverTraffic(): void;
1091
+ /**
1092
+ * Fetch from primary relay with fallback to alternate relays.
1093
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
1094
+ * @internal
1095
+ */
1096
+ private _resilientFetch;
999
1097
  /**
1000
1098
  * Start or resume a conversation with another agent.
1001
1099
  * Automatically manages thread IDs, message history, and reply chains.
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,14 @@ 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;
4076
4094
  this.did = identity.did;
4077
4095
  this.apiKey = identity.apiKey;
4078
4096
  this.signingKeyPair = identity.signingKeyPair;
@@ -4343,7 +4361,12 @@ var VoidlyAgent = class _VoidlyAgent {
4343
4361
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
4344
4362
  let plaintext = message;
4345
4363
  if (useSealed) {
4346
- plaintext = sealEnvelope(this.did, message);
4364
+ plaintext = sealEnvelope(this.did, message, {
4365
+ contentType: options.contentType,
4366
+ messageType: options.messageType,
4367
+ threadId: options.threadId,
4368
+ replyTo: options.replyTo
4369
+ });
4347
4370
  }
4348
4371
  let contentBytes;
4349
4372
  if (usePadding) {
@@ -4458,12 +4481,14 @@ var VoidlyAgent = class _VoidlyAgent {
4458
4481
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
4459
4482
  signature: (0, import_tweetnacl_util.encodeBase64)(signature),
4460
4483
  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
4484
  ttl: options.ttl
4466
4485
  };
4486
+ if (!useSealed) {
4487
+ payload.content_type = options.contentType || "text/plain";
4488
+ payload.message_type = options.messageType || "text";
4489
+ payload.thread_id = options.threadId;
4490
+ payload.reply_to = options.replyTo;
4491
+ }
4467
4492
  const relays = [this.baseUrl, ...this.fallbackRelays];
4468
4493
  let lastError = null;
4469
4494
  for (const relay of relays) {
@@ -4514,7 +4539,7 @@ var VoidlyAgent = class _VoidlyAgent {
4514
4539
  if (options.contentType) params.set("content_type", options.contentType);
4515
4540
  if (options.messageType) params.set("message_type", options.messageType);
4516
4541
  if (options.unreadOnly) params.set("unread", "true");
4517
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/raw?${params}`, {
4542
+ const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
4518
4543
  headers: { "X-Agent-Key": this.apiKey }
4519
4544
  });
4520
4545
  if (!res.ok) {
@@ -4526,7 +4551,20 @@ var VoidlyAgent = class _VoidlyAgent {
4526
4551
  for (const msg of data.messages) {
4527
4552
  try {
4528
4553
  if (this._seenMessageIds.has(msg.id)) continue;
4529
- const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4554
+ let senderEncPub;
4555
+ let senderSignPubBytes = null;
4556
+ if (msg.sender_encryption_key) {
4557
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4558
+ if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4559
+ } else if (msg.envelope) {
4560
+ const env = JSON.parse(msg.envelope);
4561
+ const senderProfile = await this.getIdentity(env.from);
4562
+ if (!senderProfile) continue;
4563
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
4564
+ if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
4565
+ } else {
4566
+ continue;
4567
+ }
4530
4568
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
4531
4569
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
4532
4570
  let rawPlaintext = null;
@@ -4694,11 +4732,19 @@ var VoidlyAgent = class _VoidlyAgent {
4694
4732
  }
4695
4733
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
4696
4734
  let senderDid = msg.from;
4735
+ let innerContentType;
4736
+ let innerMessageType;
4737
+ let innerThreadId;
4738
+ let innerReplyTo;
4697
4739
  if (wasSealed || !proto) {
4698
4740
  const unsealed = unsealEnvelope(content);
4699
4741
  if (unsealed) {
4700
4742
  content = unsealed.msg;
4701
4743
  senderDid = unsealed.from;
4744
+ innerContentType = unsealed.contentType;
4745
+ innerMessageType = unsealed.messageType;
4746
+ innerThreadId = unsealed.threadId;
4747
+ innerReplyTo = unsealed.replyTo;
4702
4748
  }
4703
4749
  }
4704
4750
  let signatureValid = false;
@@ -4719,12 +4765,11 @@ var VoidlyAgent = class _VoidlyAgent {
4719
4765
  for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4720
4766
  signatureValid = diff === 0;
4721
4767
  }
4722
- } else {
4723
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4768
+ } else if (senderSignPubBytes) {
4724
4769
  signatureValid = import_tweetnacl.default.sign.detached.verify(
4725
4770
  (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4726
4771
  signatureBytes,
4727
- senderSignPub
4772
+ senderSignPubBytes
4728
4773
  );
4729
4774
  }
4730
4775
  } catch {
@@ -4744,10 +4789,11 @@ var VoidlyAgent = class _VoidlyAgent {
4744
4789
  from: senderDid,
4745
4790
  to: msg.to,
4746
4791
  content,
4747
- contentType: msg.content_type,
4748
- messageType: msg.message_type || "text",
4749
- threadId: msg.thread_id,
4750
- replyTo: msg.reply_to,
4792
+ // v3: prefer metadata from inside ciphertext (relay can't see it)
4793
+ contentType: innerContentType || msg.content_type || "text/plain",
4794
+ messageType: innerMessageType || msg.message_type || "text",
4795
+ threadId: innerThreadId || msg.thread_id || null,
4796
+ replyTo: innerReplyTo || msg.reply_to || null,
4751
4797
  signatureValid,
4752
4798
  timestamp: msg.timestamp,
4753
4799
  expiresAt: msg.expires_at
@@ -4808,7 +4854,7 @@ var VoidlyAgent = class _VoidlyAgent {
4808
4854
  if (cached && Date.now() - cached.cachedAt < 3e5) {
4809
4855
  return cached.profile;
4810
4856
  }
4811
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/identity/${did}`);
4857
+ const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
4812
4858
  if (!res.ok) return null;
4813
4859
  const profile = await res.json();
4814
4860
  this._identityCache.set(did, { profile, cachedAt: Date.now() });
@@ -4826,7 +4872,7 @@ var VoidlyAgent = class _VoidlyAgent {
4826
4872
  if (options.query) params.set("query", options.query);
4827
4873
  if (options.capability) params.set("capability", options.capability);
4828
4874
  if (options.limit) params.set("limit", String(options.limit));
4829
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/discover?${params}`);
4875
+ const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
4830
4876
  if (!res.ok) return [];
4831
4877
  const data = await res.json();
4832
4878
  return data.agents;
@@ -6180,6 +6226,285 @@ var VoidlyAgent = class _VoidlyAgent {
6180
6226
  listener.stop();
6181
6227
  }
6182
6228
  this._listeners.clear();
6229
+ if (this._rpcListener) {
6230
+ this._rpcListener.stop();
6231
+ this._rpcListener = null;
6232
+ }
6233
+ for (const [id, pending] of this._rpcPending) {
6234
+ clearTimeout(pending.timer);
6235
+ pending.reject(new Error("Agent stopped"));
6236
+ }
6237
+ this._rpcPending.clear();
6238
+ this.disableCoverTraffic();
6239
+ }
6240
+ // ═══════════════════════════════════════════════════════════════════════════
6241
+ // AGENT RPC — Synchronous Function Invocation Between Agents
6242
+ // ═══════════════════════════════════════════════════════════════════════════
6243
+ /**
6244
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
6245
+ * The remote agent must have registered a handler via `onInvoke()`.
6246
+ *
6247
+ * @example
6248
+ * ```ts
6249
+ * // Call a translator agent
6250
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
6251
+ * text: 'Hello, world!',
6252
+ * to: 'ja',
6253
+ * });
6254
+ * console.log(result.translation); // こんにちは
6255
+ *
6256
+ * // With timeout
6257
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
6258
+ * ```
6259
+ */
6260
+ async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
6261
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6262
+ return new Promise(async (resolve, reject) => {
6263
+ const timer = setTimeout(() => {
6264
+ this._rpcPending.delete(rpcId);
6265
+ reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
6266
+ }, timeoutMs);
6267
+ this._rpcPending.set(rpcId, { resolve, reject, timer });
6268
+ try {
6269
+ await this.send(targetDid, JSON.stringify({
6270
+ jsonrpc: "2.0",
6271
+ method,
6272
+ params,
6273
+ id: rpcId
6274
+ }), { messageType: "rpc-request", threadId: rpcId });
6275
+ } catch (err) {
6276
+ clearTimeout(timer);
6277
+ this._rpcPending.delete(rpcId);
6278
+ reject(err);
6279
+ }
6280
+ });
6281
+ }
6282
+ /**
6283
+ * Register a handler for incoming RPC invocations.
6284
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
6285
+ *
6286
+ * @example
6287
+ * ```ts
6288
+ * // Register a translation capability
6289
+ * agent.onInvoke('translate', async (params, callerDid) => {
6290
+ * const result = await myTranslateFunction(params.text, params.to);
6291
+ * return { translation: result };
6292
+ * });
6293
+ *
6294
+ * // Register a search capability
6295
+ * agent.onInvoke('search', async (params) => {
6296
+ * return { results: await searchDatabase(params.query) };
6297
+ * });
6298
+ * ```
6299
+ */
6300
+ onInvoke(method, handler) {
6301
+ this._rpcHandlers.set(method, handler);
6302
+ this._ensureRpcListener();
6303
+ }
6304
+ /**
6305
+ * Remove an RPC handler.
6306
+ */
6307
+ offInvoke(method) {
6308
+ this._rpcHandlers.delete(method);
6309
+ if (this._rpcHandlers.size === 0 && this._rpcListener) {
6310
+ this._rpcListener.stop();
6311
+ this._rpcListener = null;
6312
+ }
6313
+ }
6314
+ /** @internal Start listening for RPC requests and responses */
6315
+ _ensureRpcListener() {
6316
+ if (this._rpcListener) return;
6317
+ this._rpcListener = this.listen(async (msg) => {
6318
+ try {
6319
+ const payload = JSON.parse(msg.content);
6320
+ if (payload.jsonrpc !== "2.0") return;
6321
+ if (payload.id && (payload.result !== void 0 || payload.error)) {
6322
+ const pending = this._rpcPending.get(payload.id);
6323
+ if (pending) {
6324
+ clearTimeout(pending.timer);
6325
+ this._rpcPending.delete(payload.id);
6326
+ if (payload.error) {
6327
+ pending.reject(new Error(payload.error.message || "RPC error"));
6328
+ } else {
6329
+ pending.resolve(payload.result);
6330
+ }
6331
+ }
6332
+ return;
6333
+ }
6334
+ if (payload.method && payload.id) {
6335
+ const handler = this._rpcHandlers.get(payload.method);
6336
+ if (!handler) {
6337
+ await this.send(msg.from, JSON.stringify({
6338
+ jsonrpc: "2.0",
6339
+ id: payload.id,
6340
+ error: { code: -32601, message: `Method not found: ${payload.method}` }
6341
+ }), { messageType: "rpc-response", threadId: payload.id });
6342
+ return;
6343
+ }
6344
+ try {
6345
+ const result = await handler(payload.params || {}, msg.from);
6346
+ await this.send(msg.from, JSON.stringify({
6347
+ jsonrpc: "2.0",
6348
+ id: payload.id,
6349
+ result
6350
+ }), { messageType: "rpc-response", threadId: payload.id });
6351
+ } catch (err) {
6352
+ await this.send(msg.from, JSON.stringify({
6353
+ jsonrpc: "2.0",
6354
+ id: payload.id,
6355
+ error: { code: -32e3, message: err.message || "Handler error" }
6356
+ }), { messageType: "rpc-response", threadId: payload.id });
6357
+ }
6358
+ }
6359
+ } catch {
6360
+ }
6361
+ }, { interval: 500, adaptive: false, heartbeat: false });
6362
+ }
6363
+ // ═══════════════════════════════════════════════════════════════════════════
6364
+ // P2P DIRECT MODE — Bypass Relay When Possible
6365
+ // ═══════════════════════════════════════════════════════════════════════════
6366
+ /**
6367
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
6368
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
6369
+ *
6370
+ * Falls back to relay-based send if direct delivery fails.
6371
+ *
6372
+ * @example
6373
+ * ```ts
6374
+ * // Try direct first, fall back to relay
6375
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
6376
+ * console.log(result.direct); // true if delivered directly, false if via relay
6377
+ * ```
6378
+ */
6379
+ async sendDirect(recipientDid, message, options = {}) {
6380
+ try {
6381
+ const profile = await this.getIdentity(recipientDid);
6382
+ if (profile) {
6383
+ const webhookRes = await this._timedFetch(
6384
+ `${this.baseUrl}/v1/agent/identity/${recipientDid}`,
6385
+ { headers: { "X-Agent-Key": this.apiKey } }
6386
+ );
6387
+ if (webhookRes.ok) {
6388
+ const data = await webhookRes.json();
6389
+ const webhookUrl = data.webhook_url;
6390
+ if (webhookUrl) {
6391
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
6392
+ const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
6393
+ const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
6394
+ const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
6395
+ const envelope = JSON.stringify({
6396
+ from: this.did,
6397
+ to: recipientDid,
6398
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
6399
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
6400
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6401
+ message_type: options.messageType || "text",
6402
+ thread_id: options.threadId
6403
+ });
6404
+ const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
6405
+ const directRes = await this._timedFetch(webhookUrl, {
6406
+ method: "POST",
6407
+ headers: {
6408
+ "Content-Type": "application/json",
6409
+ "X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
6410
+ "X-Voidly-Sender": this.did
6411
+ },
6412
+ body: envelope
6413
+ });
6414
+ if (directRes.ok) {
6415
+ const now = /* @__PURE__ */ new Date();
6416
+ return {
6417
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
6418
+ from: this.did,
6419
+ to: recipientDid,
6420
+ timestamp: now.toISOString(),
6421
+ expiresAt: new Date(now.getTime() + 864e5).toISOString(),
6422
+ encrypted: true,
6423
+ clientSide: true,
6424
+ direct: true
6425
+ };
6426
+ }
6427
+ }
6428
+ }
6429
+ }
6430
+ } catch {
6431
+ }
6432
+ const result = await this.send(recipientDid, message, options);
6433
+ return { ...result, direct: false };
6434
+ }
6435
+ // ═══════════════════════════════════════════════════════════════════════════
6436
+ // COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
6437
+ // ═══════════════════════════════════════════════════════════════════════════
6438
+ /**
6439
+ * Enable cover traffic — sends encrypted noise at random intervals.
6440
+ * Makes real messages indistinguishable from cover traffic for any observer
6441
+ * monitoring message timing and frequency.
6442
+ *
6443
+ * Cover messages are encrypted and padded identically to real messages.
6444
+ * The relay cannot distinguish them from real traffic.
6445
+ *
6446
+ * @example
6447
+ * ```ts
6448
+ * // Send noise every ~30s (randomized ±50%)
6449
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
6450
+ *
6451
+ * // Stop cover traffic
6452
+ * agent.disableCoverTraffic();
6453
+ * ```
6454
+ */
6455
+ enableCoverTraffic(options = {}) {
6456
+ this.disableCoverTraffic();
6457
+ const baseInterval = options.intervalMs || 3e4;
6458
+ const sendNoise = async () => {
6459
+ try {
6460
+ const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
6461
+ await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
6462
+ messageType: "ping",
6463
+ // use 'ping' type — indistinguishable in encrypted payload
6464
+ ttl: 60
6465
+ // short TTL — noise auto-expires
6466
+ });
6467
+ } catch {
6468
+ }
6469
+ };
6470
+ const scheduleNext = () => {
6471
+ const jitter = baseInterval * (0.5 + Math.random());
6472
+ this._coverTrafficTimer = setTimeout(async () => {
6473
+ await sendNoise();
6474
+ if (this._coverTrafficTimer !== null) scheduleNext();
6475
+ }, jitter);
6476
+ };
6477
+ scheduleNext();
6478
+ }
6479
+ /**
6480
+ * Disable cover traffic.
6481
+ */
6482
+ disableCoverTraffic() {
6483
+ if (this._coverTrafficTimer !== null) {
6484
+ clearTimeout(this._coverTrafficTimer);
6485
+ this._coverTrafficTimer = null;
6486
+ }
6487
+ }
6488
+ // ═══════════════════════════════════════════════════════════════════════════
6489
+ // RESILIENT OPERATIONS — Fallback for All Operations
6490
+ // ═══════════════════════════════════════════════════════════════════════════
6491
+ /**
6492
+ * Fetch from primary relay with fallback to alternate relays.
6493
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
6494
+ * @internal
6495
+ */
6496
+ async _resilientFetch(path, init) {
6497
+ const relays = [this.baseUrl, ...this.fallbackRelays];
6498
+ let lastError = null;
6499
+ for (const relay of relays) {
6500
+ try {
6501
+ const res = await this._timedFetch(`${relay}${path}`, init);
6502
+ if (res.ok || res.status >= 400 && res.status < 500) return res;
6503
+ } catch (err) {
6504
+ lastError = err instanceof Error ? err : new Error(String(err));
6505
+ }
6506
+ }
6507
+ throw lastError || new Error("All relays failed");
6183
6508
  }
6184
6509
  // ═══════════════════════════════════════════════════════════════════════════
6185
6510
  // CONVERSATIONS — Thread Management
@@ -6318,13 +6643,11 @@ var VoidlyAgent = class _VoidlyAgent {
6318
6643
  return {
6319
6644
  relayCanSee: [
6320
6645
  "Your DID (public identifier)",
6321
- ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6646
+ "Recipient DIDs (relay needs them for routing)",
6322
6647
  ...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)",
6648
+ ...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
6325
6649
  "Channel membership (but NOT channel message content with client-side encryption)",
6326
6650
  "Capability registrations",
6327
- "Online/offline status",
6328
6651
  "Approximate message size (even with padding, bounded to power-of-2)"
6329
6652
  ],
6330
6653
  relayCannotSee: [
@@ -6334,7 +6657,11 @@ var VoidlyAgent = class _VoidlyAgent {
6334
6657
  "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6335
6658
  "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6336
6659
  "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6337
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6660
+ ...this.sealedSender ? [
6661
+ 'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
6662
+ "Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
6663
+ "Message count (not incremented for sealed senders)"
6664
+ ] : [],
6338
6665
  ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
6339
6666
  ],
6340
6667
  protections: [
@@ -6352,22 +6679,32 @@ var VoidlyAgent = class _VoidlyAgent {
6352
6679
  "Request timeouts (AbortController on all HTTP, configurable)",
6353
6680
  "Request validation (fromCredentials validates key sizes and format)",
6354
6681
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
6355
- ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
6682
+ ...this.sealedSender ? [
6683
+ "Sealed sender (relay cannot see who sent a message)",
6684
+ "Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
6685
+ ] : [],
6356
6686
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6357
- ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6687
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6358
6688
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6359
6689
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6690
+ ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6691
+ "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6692
+ "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
6693
+ "Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
6360
6694
  "Auto-retry with exponential backoff",
6361
6695
  "Offline message queue",
6362
6696
  "did:key interoperability (W3C standard DID format)"
6363
6697
  ],
6364
6698
  gaps: [
6365
6699
  ...!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)",
6700
+ ...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"],
6701
+ "Relay sees channel membership, task delegation, trust scores (social graph)",
6702
+ ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6367
6703
  "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6368
6704
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6369
6705
  ...!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"] : []
6706
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
6707
+ ...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
6371
6708
  ]
6372
6709
  };
6373
6710
  }
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,14 @@ 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;
4065
4083
  this.did = identity.did;
4066
4084
  this.apiKey = identity.apiKey;
4067
4085
  this.signingKeyPair = identity.signingKeyPair;
@@ -4332,7 +4350,12 @@ var VoidlyAgent = class _VoidlyAgent {
4332
4350
  const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
4333
4351
  let plaintext = message;
4334
4352
  if (useSealed) {
4335
- plaintext = sealEnvelope(this.did, message);
4353
+ plaintext = sealEnvelope(this.did, message, {
4354
+ contentType: options.contentType,
4355
+ messageType: options.messageType,
4356
+ threadId: options.threadId,
4357
+ replyTo: options.replyTo
4358
+ });
4336
4359
  }
4337
4360
  let contentBytes;
4338
4361
  if (usePadding) {
@@ -4447,12 +4470,14 @@ var VoidlyAgent = class _VoidlyAgent {
4447
4470
  nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
4448
4471
  signature: (0, import_tweetnacl_util.encodeBase64)(signature),
4449
4472
  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
4473
  ttl: options.ttl
4455
4474
  };
4475
+ if (!useSealed) {
4476
+ payload.content_type = options.contentType || "text/plain";
4477
+ payload.message_type = options.messageType || "text";
4478
+ payload.thread_id = options.threadId;
4479
+ payload.reply_to = options.replyTo;
4480
+ }
4456
4481
  const relays = [this.baseUrl, ...this.fallbackRelays];
4457
4482
  let lastError = null;
4458
4483
  for (const relay of relays) {
@@ -4503,7 +4528,7 @@ var VoidlyAgent = class _VoidlyAgent {
4503
4528
  if (options.contentType) params.set("content_type", options.contentType);
4504
4529
  if (options.messageType) params.set("message_type", options.messageType);
4505
4530
  if (options.unreadOnly) params.set("unread", "true");
4506
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/raw?${params}`, {
4531
+ const res = await this._resilientFetch(`/v1/agent/receive/raw?${params}`, {
4507
4532
  headers: { "X-Agent-Key": this.apiKey }
4508
4533
  });
4509
4534
  if (!res.ok) {
@@ -4515,7 +4540,20 @@ var VoidlyAgent = class _VoidlyAgent {
4515
4540
  for (const msg of data.messages) {
4516
4541
  try {
4517
4542
  if (this._seenMessageIds.has(msg.id)) continue;
4518
- const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4543
+ let senderEncPub;
4544
+ let senderSignPubBytes = null;
4545
+ if (msg.sender_encryption_key) {
4546
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
4547
+ if (msg.sender_signing_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4548
+ } else if (msg.envelope) {
4549
+ const env = JSON.parse(msg.envelope);
4550
+ const senderProfile = await this.getIdentity(env.from);
4551
+ if (!senderProfile) continue;
4552
+ senderEncPub = (0, import_tweetnacl_util.decodeBase64)(senderProfile.encryption_public_key);
4553
+ if (senderProfile.signing_public_key) senderSignPubBytes = (0, import_tweetnacl_util.decodeBase64)(senderProfile.signing_public_key);
4554
+ } else {
4555
+ continue;
4556
+ }
4519
4557
  const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
4520
4558
  const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
4521
4559
  let rawPlaintext = null;
@@ -4683,11 +4721,19 @@ var VoidlyAgent = class _VoidlyAgent {
4683
4721
  }
4684
4722
  let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
4685
4723
  let senderDid = msg.from;
4724
+ let innerContentType;
4725
+ let innerMessageType;
4726
+ let innerThreadId;
4727
+ let innerReplyTo;
4686
4728
  if (wasSealed || !proto) {
4687
4729
  const unsealed = unsealEnvelope(content);
4688
4730
  if (unsealed) {
4689
4731
  content = unsealed.msg;
4690
4732
  senderDid = unsealed.from;
4733
+ innerContentType = unsealed.contentType;
4734
+ innerMessageType = unsealed.messageType;
4735
+ innerThreadId = unsealed.threadId;
4736
+ innerReplyTo = unsealed.replyTo;
4691
4737
  }
4692
4738
  }
4693
4739
  let signatureValid = false;
@@ -4708,12 +4754,11 @@ var VoidlyAgent = class _VoidlyAgent {
4708
4754
  for (let i = 0; i < expectedHmac.length; i++) diff |= expectedHmac[i] ^ signatureBytes[i];
4709
4755
  signatureValid = diff === 0;
4710
4756
  }
4711
- } else {
4712
- const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
4757
+ } else if (senderSignPubBytes) {
4713
4758
  signatureValid = import_tweetnacl.default.sign.detached.verify(
4714
4759
  (0, import_tweetnacl_util.decodeUTF8)(envelopeStr),
4715
4760
  signatureBytes,
4716
- senderSignPub
4761
+ senderSignPubBytes
4717
4762
  );
4718
4763
  }
4719
4764
  } catch {
@@ -4733,10 +4778,11 @@ var VoidlyAgent = class _VoidlyAgent {
4733
4778
  from: senderDid,
4734
4779
  to: msg.to,
4735
4780
  content,
4736
- contentType: msg.content_type,
4737
- messageType: msg.message_type || "text",
4738
- threadId: msg.thread_id,
4739
- replyTo: msg.reply_to,
4781
+ // v3: prefer metadata from inside ciphertext (relay can't see it)
4782
+ contentType: innerContentType || msg.content_type || "text/plain",
4783
+ messageType: innerMessageType || msg.message_type || "text",
4784
+ threadId: innerThreadId || msg.thread_id || null,
4785
+ replyTo: innerReplyTo || msg.reply_to || null,
4740
4786
  signatureValid,
4741
4787
  timestamp: msg.timestamp,
4742
4788
  expiresAt: msg.expires_at
@@ -4797,7 +4843,7 @@ var VoidlyAgent = class _VoidlyAgent {
4797
4843
  if (cached && Date.now() - cached.cachedAt < 3e5) {
4798
4844
  return cached.profile;
4799
4845
  }
4800
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/identity/${did}`);
4846
+ const res = await this._resilientFetch(`/v1/agent/identity/${did}`);
4801
4847
  if (!res.ok) return null;
4802
4848
  const profile = await res.json();
4803
4849
  this._identityCache.set(did, { profile, cachedAt: Date.now() });
@@ -4815,7 +4861,7 @@ var VoidlyAgent = class _VoidlyAgent {
4815
4861
  if (options.query) params.set("query", options.query);
4816
4862
  if (options.capability) params.set("capability", options.capability);
4817
4863
  if (options.limit) params.set("limit", String(options.limit));
4818
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/discover?${params}`);
4864
+ const res = await this._resilientFetch(`/v1/agent/discover?${params}`);
4819
4865
  if (!res.ok) return [];
4820
4866
  const data = await res.json();
4821
4867
  return data.agents;
@@ -6169,6 +6215,285 @@ var VoidlyAgent = class _VoidlyAgent {
6169
6215
  listener.stop();
6170
6216
  }
6171
6217
  this._listeners.clear();
6218
+ if (this._rpcListener) {
6219
+ this._rpcListener.stop();
6220
+ this._rpcListener = null;
6221
+ }
6222
+ for (const [id, pending] of this._rpcPending) {
6223
+ clearTimeout(pending.timer);
6224
+ pending.reject(new Error("Agent stopped"));
6225
+ }
6226
+ this._rpcPending.clear();
6227
+ this.disableCoverTraffic();
6228
+ }
6229
+ // ═══════════════════════════════════════════════════════════════════════════
6230
+ // AGENT RPC — Synchronous Function Invocation Between Agents
6231
+ // ═══════════════════════════════════════════════════════════════════════════
6232
+ /**
6233
+ * Invoke a function on a remote agent. Synchronous RPC over encrypted messaging.
6234
+ * The remote agent must have registered a handler via `onInvoke()`.
6235
+ *
6236
+ * @example
6237
+ * ```ts
6238
+ * // Call a translator agent
6239
+ * const result = await agent.invoke('did:voidly:translator', 'translate', {
6240
+ * text: 'Hello, world!',
6241
+ * to: 'ja',
6242
+ * });
6243
+ * console.log(result.translation); // こんにちは
6244
+ *
6245
+ * // With timeout
6246
+ * const data = await agent.invoke(peerDid, 'analyze', { url: '...' }, 15000);
6247
+ * ```
6248
+ */
6249
+ async invoke(targetDid, method, params = {}, timeoutMs = 3e4) {
6250
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6251
+ return new Promise(async (resolve, reject) => {
6252
+ const timer = setTimeout(() => {
6253
+ this._rpcPending.delete(rpcId);
6254
+ reject(new Error(`RPC timeout: ${method}@${targetDid} after ${timeoutMs}ms`));
6255
+ }, timeoutMs);
6256
+ this._rpcPending.set(rpcId, { resolve, reject, timer });
6257
+ try {
6258
+ await this.send(targetDid, JSON.stringify({
6259
+ jsonrpc: "2.0",
6260
+ method,
6261
+ params,
6262
+ id: rpcId
6263
+ }), { messageType: "rpc-request", threadId: rpcId });
6264
+ } catch (err) {
6265
+ clearTimeout(timer);
6266
+ this._rpcPending.delete(rpcId);
6267
+ reject(err);
6268
+ }
6269
+ });
6270
+ }
6271
+ /**
6272
+ * Register a handler for incoming RPC invocations.
6273
+ * When another agent calls `invoke(yourDid, method, params)`, your handler runs.
6274
+ *
6275
+ * @example
6276
+ * ```ts
6277
+ * // Register a translation capability
6278
+ * agent.onInvoke('translate', async (params, callerDid) => {
6279
+ * const result = await myTranslateFunction(params.text, params.to);
6280
+ * return { translation: result };
6281
+ * });
6282
+ *
6283
+ * // Register a search capability
6284
+ * agent.onInvoke('search', async (params) => {
6285
+ * return { results: await searchDatabase(params.query) };
6286
+ * });
6287
+ * ```
6288
+ */
6289
+ onInvoke(method, handler) {
6290
+ this._rpcHandlers.set(method, handler);
6291
+ this._ensureRpcListener();
6292
+ }
6293
+ /**
6294
+ * Remove an RPC handler.
6295
+ */
6296
+ offInvoke(method) {
6297
+ this._rpcHandlers.delete(method);
6298
+ if (this._rpcHandlers.size === 0 && this._rpcListener) {
6299
+ this._rpcListener.stop();
6300
+ this._rpcListener = null;
6301
+ }
6302
+ }
6303
+ /** @internal Start listening for RPC requests and responses */
6304
+ _ensureRpcListener() {
6305
+ if (this._rpcListener) return;
6306
+ this._rpcListener = this.listen(async (msg) => {
6307
+ try {
6308
+ const payload = JSON.parse(msg.content);
6309
+ if (payload.jsonrpc !== "2.0") return;
6310
+ if (payload.id && (payload.result !== void 0 || payload.error)) {
6311
+ const pending = this._rpcPending.get(payload.id);
6312
+ if (pending) {
6313
+ clearTimeout(pending.timer);
6314
+ this._rpcPending.delete(payload.id);
6315
+ if (payload.error) {
6316
+ pending.reject(new Error(payload.error.message || "RPC error"));
6317
+ } else {
6318
+ pending.resolve(payload.result);
6319
+ }
6320
+ }
6321
+ return;
6322
+ }
6323
+ if (payload.method && payload.id) {
6324
+ const handler = this._rpcHandlers.get(payload.method);
6325
+ if (!handler) {
6326
+ await this.send(msg.from, JSON.stringify({
6327
+ jsonrpc: "2.0",
6328
+ id: payload.id,
6329
+ error: { code: -32601, message: `Method not found: ${payload.method}` }
6330
+ }), { messageType: "rpc-response", threadId: payload.id });
6331
+ return;
6332
+ }
6333
+ try {
6334
+ const result = await handler(payload.params || {}, msg.from);
6335
+ await this.send(msg.from, JSON.stringify({
6336
+ jsonrpc: "2.0",
6337
+ id: payload.id,
6338
+ result
6339
+ }), { messageType: "rpc-response", threadId: payload.id });
6340
+ } catch (err) {
6341
+ await this.send(msg.from, JSON.stringify({
6342
+ jsonrpc: "2.0",
6343
+ id: payload.id,
6344
+ error: { code: -32e3, message: err.message || "Handler error" }
6345
+ }), { messageType: "rpc-response", threadId: payload.id });
6346
+ }
6347
+ }
6348
+ } catch {
6349
+ }
6350
+ }, { interval: 500, adaptive: false, heartbeat: false });
6351
+ }
6352
+ // ═══════════════════════════════════════════════════════════════════════════
6353
+ // P2P DIRECT MODE — Bypass Relay When Possible
6354
+ // ═══════════════════════════════════════════════════════════════════════════
6355
+ /**
6356
+ * Send a message directly to a peer's webhook endpoint, bypassing the relay entirely.
6357
+ * The relay never sees the message — true peer-to-peer encrypted delivery.
6358
+ *
6359
+ * Falls back to relay-based send if direct delivery fails.
6360
+ *
6361
+ * @example
6362
+ * ```ts
6363
+ * // Try direct first, fall back to relay
6364
+ * const result = await agent.sendDirect('did:voidly:peer', 'Hello P2P!');
6365
+ * console.log(result.direct); // true if delivered directly, false if via relay
6366
+ * ```
6367
+ */
6368
+ async sendDirect(recipientDid, message, options = {}) {
6369
+ try {
6370
+ const profile = await this.getIdentity(recipientDid);
6371
+ if (profile) {
6372
+ const webhookRes = await this._timedFetch(
6373
+ `${this.baseUrl}/v1/agent/identity/${recipientDid}`,
6374
+ { headers: { "X-Agent-Key": this.apiKey } }
6375
+ );
6376
+ if (webhookRes.ok) {
6377
+ const data = await webhookRes.json();
6378
+ const webhookUrl = data.webhook_url;
6379
+ if (webhookUrl) {
6380
+ const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
6381
+ const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
6382
+ const plaintext = (0, import_tweetnacl_util.decodeUTF8)(message);
6383
+ const ciphertext = import_tweetnacl.default.box(plaintext, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
6384
+ const envelope = JSON.stringify({
6385
+ from: this.did,
6386
+ to: recipientDid,
6387
+ ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
6388
+ nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
6389
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6390
+ message_type: options.messageType || "text",
6391
+ thread_id: options.threadId
6392
+ });
6393
+ const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelope), this.signingKeyPair.secretKey);
6394
+ const directRes = await this._timedFetch(webhookUrl, {
6395
+ method: "POST",
6396
+ headers: {
6397
+ "Content-Type": "application/json",
6398
+ "X-Voidly-Signature": `sha256=${(0, import_tweetnacl_util.encodeBase64)(signature)}`,
6399
+ "X-Voidly-Sender": this.did
6400
+ },
6401
+ body: envelope
6402
+ });
6403
+ if (directRes.ok) {
6404
+ const now = /* @__PURE__ */ new Date();
6405
+ return {
6406
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
6407
+ from: this.did,
6408
+ to: recipientDid,
6409
+ timestamp: now.toISOString(),
6410
+ expiresAt: new Date(now.getTime() + 864e5).toISOString(),
6411
+ encrypted: true,
6412
+ clientSide: true,
6413
+ direct: true
6414
+ };
6415
+ }
6416
+ }
6417
+ }
6418
+ }
6419
+ } catch {
6420
+ }
6421
+ const result = await this.send(recipientDid, message, options);
6422
+ return { ...result, direct: false };
6423
+ }
6424
+ // ═══════════════════════════════════════════════════════════════════════════
6425
+ // COVER TRAFFIC — Noise Protocol for Traffic Analysis Resistance
6426
+ // ═══════════════════════════════════════════════════════════════════════════
6427
+ /**
6428
+ * Enable cover traffic — sends encrypted noise at random intervals.
6429
+ * Makes real messages indistinguishable from cover traffic for any observer
6430
+ * monitoring message timing and frequency.
6431
+ *
6432
+ * Cover messages are encrypted and padded identically to real messages.
6433
+ * The relay cannot distinguish them from real traffic.
6434
+ *
6435
+ * @example
6436
+ * ```ts
6437
+ * // Send noise every ~30s (randomized ±50%)
6438
+ * agent.enableCoverTraffic({ intervalMs: 30000 });
6439
+ *
6440
+ * // Stop cover traffic
6441
+ * agent.disableCoverTraffic();
6442
+ * ```
6443
+ */
6444
+ enableCoverTraffic(options = {}) {
6445
+ this.disableCoverTraffic();
6446
+ const baseInterval = options.intervalMs || 3e4;
6447
+ const sendNoise = async () => {
6448
+ try {
6449
+ const noise = import_tweetnacl.default.randomBytes(128 + Math.floor(Math.random() * 384));
6450
+ await this.send(this.did, (0, import_tweetnacl_util.encodeBase64)(noise), {
6451
+ messageType: "ping",
6452
+ // use 'ping' type — indistinguishable in encrypted payload
6453
+ ttl: 60
6454
+ // short TTL — noise auto-expires
6455
+ });
6456
+ } catch {
6457
+ }
6458
+ };
6459
+ const scheduleNext = () => {
6460
+ const jitter = baseInterval * (0.5 + Math.random());
6461
+ this._coverTrafficTimer = setTimeout(async () => {
6462
+ await sendNoise();
6463
+ if (this._coverTrafficTimer !== null) scheduleNext();
6464
+ }, jitter);
6465
+ };
6466
+ scheduleNext();
6467
+ }
6468
+ /**
6469
+ * Disable cover traffic.
6470
+ */
6471
+ disableCoverTraffic() {
6472
+ if (this._coverTrafficTimer !== null) {
6473
+ clearTimeout(this._coverTrafficTimer);
6474
+ this._coverTrafficTimer = null;
6475
+ }
6476
+ }
6477
+ // ═══════════════════════════════════════════════════════════════════════════
6478
+ // RESILIENT OPERATIONS — Fallback for All Operations
6479
+ // ═══════════════════════════════════════════════════════════════════════════
6480
+ /**
6481
+ * Fetch from primary relay with fallback to alternate relays.
6482
+ * Unlike _timedFetch which only hits one URL, this tries all known relays.
6483
+ * @internal
6484
+ */
6485
+ async _resilientFetch(path, init) {
6486
+ const relays = [this.baseUrl, ...this.fallbackRelays];
6487
+ let lastError = null;
6488
+ for (const relay of relays) {
6489
+ try {
6490
+ const res = await this._timedFetch(`${relay}${path}`, init);
6491
+ if (res.ok || res.status >= 400 && res.status < 500) return res;
6492
+ } catch (err) {
6493
+ lastError = err instanceof Error ? err : new Error(String(err));
6494
+ }
6495
+ }
6496
+ throw lastError || new Error("All relays failed");
6172
6497
  }
6173
6498
  // ═══════════════════════════════════════════════════════════════════════════
6174
6499
  // CONVERSATIONS — Thread Management
@@ -6307,13 +6632,11 @@ var VoidlyAgent = class _VoidlyAgent {
6307
6632
  return {
6308
6633
  relayCanSee: [
6309
6634
  "Your DID (public identifier)",
6310
- ...this.sealedSender ? [] : ["Who you message (recipient DIDs)"],
6635
+ "Recipient DIDs (relay needs them for routing)",
6311
6636
  ...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)",
6637
+ ...this.sealedSender ? [] : ["Message types, thread IDs, content types (sent in cleartext without sealed sender)"],
6314
6638
  "Channel membership (but NOT channel message content with client-side encryption)",
6315
6639
  "Capability registrations",
6316
- "Online/offline status",
6317
6640
  "Approximate message size (even with padding, bounded to power-of-2)"
6318
6641
  ],
6319
6642
  relayCannotSee: [
@@ -6323,7 +6646,11 @@ var VoidlyAgent = class _VoidlyAgent {
6323
6646
  "Past message keys (forward secrecy \u2014 hash ratchet + DH ratchet, old keys deleted)",
6324
6647
  "Future message keys (post-compromise recovery via DH ratchet \u2014 compromise heals after one round-trip)",
6325
6648
  "Channel message content (client-side nacl.secretbox encryption \u2014 relay stores only ciphertext)",
6326
- ...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : [],
6649
+ ...this.sealedSender ? [
6650
+ 'Sender identity (sealed inside ciphertext \u2014 relay stores "sealed" not your DID)',
6651
+ "Message types, thread IDs, reply chains (packed inside ciphertext in v3)",
6652
+ "Message count (not incremented for sealed senders)"
6653
+ ] : [],
6327
6654
  ...this.deniable ? ["Who authored a message (HMAC is symmetric \u2014 either party could have produced it)"] : []
6328
6655
  ],
6329
6656
  protections: [
@@ -6341,22 +6668,32 @@ var VoidlyAgent = class _VoidlyAgent {
6341
6668
  "Request timeouts (AbortController on all HTTP, configurable)",
6342
6669
  "Request validation (fromCredentials validates key sizes and format)",
6343
6670
  ...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
6344
- ...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
6671
+ ...this.sealedSender ? [
6672
+ "Sealed sender (relay cannot see who sent a message)",
6673
+ "Metadata privacy (v3 \u2014 thread_id, message_type, reply_to packed inside ciphertext, stripped from relay storage)"
6674
+ ] : [],
6345
6675
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6346
- ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
6676
+ ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6347
6677
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6348
6678
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6679
+ ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6680
+ "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6681
+ "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
6682
+ "Resilient operations (receive, discover, identity \u2014 all try fallback relays)",
6349
6683
  "Auto-retry with exponential backoff",
6350
6684
  "Offline message queue",
6351
6685
  "did:key interoperability (W3C standard DID format)"
6352
6686
  ],
6353
6687
  gaps: [
6354
6688
  ...!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)",
6689
+ ...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"],
6690
+ "Relay sees channel membership, task delegation, trust scores (social graph)",
6691
+ ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6356
6692
  "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6357
6693
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6358
6694
  ...!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"] : []
6695
+ ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
6696
+ ...this._coverTrafficTimer === null ? ["No cover traffic \u2014 call enableCoverTraffic() to resist traffic analysis"] : []
6360
6697
  ]
6361
6698
  };
6362
6699
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "3.0.0",
4
- "description": "E2E encrypted agent-to-agent communication SDK — Double Ratchet, X3DH, deniable auth, ML-KEM-768 post-quantum, client-side channels",
3
+ "version": "3.1.0",
4
+ "description": "E2E encrypted agent-to-agent communication SDK — Double Ratchet, X3DH, deniable auth, ML-KEM-768 post-quantum, metadata privacy, RPC, P2P direct, cover traffic",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",