@pingagent/sdk 0.1.5 → 0.1.7

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.ts CHANGED
@@ -486,8 +486,19 @@ interface WsSubscriptionOptions {
486
486
  onMessage: (envelope: any, conversationId: string) => void;
487
487
  onControl?: (control: WsControlPayload, conversationId: string) => void;
488
488
  onError?: (err: Error) => void;
489
+ /** Optional debug hook for observability (non-fatal events). */
490
+ onDebug?: (info: {
491
+ event: string;
492
+ conversationId?: string;
493
+ detail?: Record<string, unknown>;
494
+ }) => void;
489
495
  /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
490
496
  onOpen?: (conversationId: string) => void;
497
+ /**
498
+ * When true (default), drop ws_message envelopes sent by myself (sender_did === myDid).
499
+ * Set to false when you intentionally run multiple devices/processes under the same DID and want cross-device delivery.
500
+ */
501
+ ignoreSelfMessages?: boolean;
491
502
  /**
492
503
  * Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
493
504
  * This is client-side only (WS ping/pong control frames) and does not add inbox HTTP fetch cost.
@@ -517,6 +528,8 @@ declare class WsSubscription {
517
528
  private stopped;
518
529
  /** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
519
530
  private stoppedConversations;
531
+ private lastParseErrorAtByConv;
532
+ private lastFrameAtByConv;
520
533
  private heartbeatTimer;
521
534
  private heartbeatStates;
522
535
  constructor(opts: WsSubscriptionOptions);
@@ -531,6 +544,7 @@ declare class WsSubscription {
531
544
  private connectAsync;
532
545
  private heartbeatTick;
533
546
  private scheduleReconnect;
547
+ private isSubscribableConversationType;
534
548
  private connectAll;
535
549
  private syncConnections;
536
550
  }
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  loadIdentity,
17
17
  saveIdentity,
18
18
  updateStoredToken
19
- } from "./chunk-V4IHFEJC.js";
19
+ } from "./chunk-OVLKR4JF.js";
20
20
  export {
21
21
  A2AAdapter,
22
22
  ContactManager,
@@ -5,7 +5,7 @@ import {
5
5
  ensureTokenValid,
6
6
  loadIdentity,
7
7
  updateStoredToken
8
- } from "./chunk-V4IHFEJC.js";
8
+ } from "./chunk-OVLKR4JF.js";
9
9
 
10
10
  // src/web-server.ts
11
11
  import * as fs from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingagent/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -26,9 +26,9 @@
26
26
  "commander": "^13.0.0",
27
27
  "uuid": "^11.0.0",
28
28
  "ws": "^8.0.0",
29
- "@pingagent/protocol": "0.1.1",
30
29
  "@pingagent/schemas": "0.1.1",
31
- "@pingagent/a2a": "0.1.1"
30
+ "@pingagent/a2a": "0.1.1",
31
+ "@pingagent/protocol": "0.1.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/better-sqlite3": "^7.6.0",
@@ -19,8 +19,15 @@ export interface WsSubscriptionOptions {
19
19
  onMessage: (envelope: any, conversationId: string) => void;
20
20
  onControl?: (control: WsControlPayload, conversationId: string) => void;
21
21
  onError?: (err: Error) => void;
22
+ /** Optional debug hook for observability (non-fatal events). */
23
+ onDebug?: (info: { event: string; conversationId?: string; detail?: Record<string, unknown> }) => void;
22
24
  /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
23
25
  onOpen?: (conversationId: string) => void;
26
+ /**
27
+ * When true (default), drop ws_message envelopes sent by myself (sender_did === myDid).
28
+ * Set to false when you intentionally run multiple devices/processes under the same DID and want cross-device delivery.
29
+ */
30
+ ignoreSelfMessages?: boolean;
24
31
 
25
32
  /**
26
33
  * Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
@@ -50,8 +57,8 @@ const LIST_CONVERSATIONS_INTERVAL_MS = 60_000;
50
57
 
51
58
  const DEFAULT_HEARTBEAT = {
52
59
  enable: true,
53
- idleThresholdMs: 25_000,
54
- pingIntervalMs: 30_000,
60
+ idleThresholdMs: 10_000,
61
+ pingIntervalMs: 15_000,
55
62
  pongTimeoutMs: 10_000,
56
63
  maxMissedPongs: 2,
57
64
  tickMs: 5_000,
@@ -67,6 +74,8 @@ export class WsSubscription {
67
74
  private stopped = false;
68
75
  /** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
69
76
  private stoppedConversations = new Set<string>();
77
+ private lastParseErrorAtByConv = new Map<string, number>();
78
+ private lastFrameAtByConv = new Map<string, number>();
70
79
 
71
80
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
72
81
  private heartbeatStates = new Map<
@@ -119,6 +128,8 @@ export class WsSubscription {
119
128
  }
120
129
  this.reconnectAttempts.clear();
121
130
  this.stoppedConversations.clear();
131
+ this.lastParseErrorAtByConv.clear();
132
+ this.lastFrameAtByConv.clear();
122
133
 
123
134
  for (const state of this.heartbeatStates.values()) {
124
135
  if (state.pongTimeout) clearTimeout(state.pongTimeout);
@@ -150,6 +161,8 @@ export class WsSubscription {
150
161
  }
151
162
  this.connections.delete(conversationId);
152
163
  }
164
+ this.lastParseErrorAtByConv.delete(conversationId);
165
+ this.lastFrameAtByConv.delete(conversationId);
153
166
  }
154
167
 
155
168
  private wsUrl(conversationId: string): string {
@@ -187,10 +200,8 @@ export class WsSubscription {
187
200
  // ws_connected will arrive as first message
188
201
  });
189
202
 
190
- ws.on('message', (data: Buffer | string) => {
203
+ ws.on('message', (data: any) => {
191
204
  try {
192
- const msg = JSON.parse(data.toString());
193
-
194
205
  // Any server frame counts as activity; keep connection stable and avoid unnecessary pings.
195
206
  const state = this.heartbeatStates.get(conversationId);
196
207
  if (state) {
@@ -202,16 +213,67 @@ export class WsSubscription {
202
213
  }
203
214
  }
204
215
 
205
- if (msg.type === 'ws_message' && msg.envelope) {
216
+ const rawText =
217
+ typeof data === 'string'
218
+ ? data
219
+ : Buffer.isBuffer(data)
220
+ ? data.toString()
221
+ : Array.isArray(data)
222
+ ? Buffer.concat(data).toString()
223
+ : data instanceof ArrayBuffer
224
+ ? Buffer.from(new Uint8Array(data)).toString()
225
+ : // Fallback: try best-effort stringification
226
+ String(data);
227
+
228
+ this.lastFrameAtByConv.set(conversationId, Date.now());
229
+
230
+ const msg = JSON.parse(rawText);
231
+
232
+ if (msg.type === 'ws_connected') {
233
+ this.opts.onDebug?.({
234
+ event: 'ws_connected',
235
+ conversationId,
236
+ detail: {
237
+ your_did: msg.your_did,
238
+ server_ts_ms: msg.server_ts_ms,
239
+ conversation_id: msg.conversation_id,
240
+ },
241
+ });
242
+ } else if (msg.type === 'ws_message' && msg.envelope) {
206
243
  const env = msg.envelope;
207
- if (env.sender_did !== this.opts.myDid) {
244
+ const ignoreSelf = this.opts.ignoreSelfMessages ?? true;
245
+ if (!ignoreSelf || env.sender_did !== this.opts.myDid) {
208
246
  this.opts.onMessage(env, conversationId);
247
+ } else {
248
+ this.opts.onDebug?.({
249
+ event: 'ws_message_ignored_self',
250
+ conversationId,
251
+ detail: { sender_did: env.sender_did, message_id: env.message_id, seq: env.seq },
252
+ });
209
253
  }
210
254
  } else if (msg.type === 'ws_control' && msg.control) {
211
255
  this.opts.onControl?.(msg.control, conversationId);
256
+ this.opts.onDebug?.({ event: 'ws_control', conversationId, detail: msg.control });
257
+ } else if (msg.type === 'ws_receipt' && msg.receipt) {
258
+ this.opts.onDebug?.({ event: 'ws_receipt', conversationId, detail: msg.receipt });
259
+ } else if (msg?.type) {
260
+ this.opts.onDebug?.({ event: 'ws_unknown_type', conversationId, detail: { type: msg.type } });
261
+ }
262
+ } catch (e: any) {
263
+ // Observability: parse failures are otherwise silent and look like "server never pushes".
264
+ const now = Date.now();
265
+ const last = this.lastParseErrorAtByConv.get(conversationId) ?? 0;
266
+ if (now - last > 30_000) {
267
+ this.lastParseErrorAtByConv.set(conversationId, now);
268
+ this.opts.onDebug?.({
269
+ event: 'ws_parse_error',
270
+ conversationId,
271
+ detail: {
272
+ message: e?.message ?? String(e),
273
+ last_frame_at_ms: this.lastFrameAtByConv.get(conversationId) ?? null,
274
+ },
275
+ });
212
276
  }
213
- } catch {
214
- // ignore parse errors
215
277
  }
216
278
  });
217
279
 
@@ -271,6 +333,14 @@ export class WsSubscription {
271
333
  continue;
272
334
  }
273
335
 
336
+ // Application-level keepalive for CDN/NAT idle timeouts (more reliable than ping/pong alone).
337
+ // Receiver can ignore this message.
338
+ try {
339
+ ws.send(JSON.stringify({ type: 'hb' }));
340
+ } catch {
341
+ // ignore; ping/pong + reconnect will handle dead sockets
342
+ }
343
+
274
344
  ws.ping();
275
345
  state.lastPingAt = now;
276
346
  const jitterFactor = 1 + (Math.random() - 0.5) * 2 * jitter;
@@ -280,7 +350,7 @@ export class WsSubscription {
280
350
  state.missedPongs += 1;
281
351
  state.pongTimeout = null;
282
352
 
283
- if (state.missedPongs > maxMissedPongs) {
353
+ if (state.missedPongs >= maxMissedPongs) {
284
354
  try {
285
355
  // Trigger close handler -> scheduleReconnect
286
356
  ws.close(1001, 'pong timeout');
@@ -312,10 +382,15 @@ export class WsSubscription {
312
382
  this.reconnectTimers.set(conversationId, timer);
313
383
  }
314
384
 
385
+ private isSubscribableConversationType(type: string): boolean {
386
+ // Server may return pending_dm alongside dm listings. WS upgrade is supported for any conversation_id.
387
+ return type === 'dm' || type === 'pending_dm' || type === 'channel' || type === 'group';
388
+ }
389
+
315
390
  private async connectAll(): Promise<void> {
316
391
  try {
317
392
  const convos = await this.opts.listConversations();
318
- const subscribable = convos.filter((c) => c.type === 'dm' || c.type === 'channel');
393
+ const subscribable = convos.filter((c) => this.isSubscribableConversationType(c.type));
319
394
  for (const c of subscribable) {
320
395
  void this.connectAsync(c.conversation_id);
321
396
  }
@@ -329,7 +404,7 @@ export class WsSubscription {
329
404
  try {
330
405
  const convos = await this.opts.listConversations();
331
406
  const subscribableIds = new Set(
332
- convos.filter((c) => c.type === 'dm' || c.type === 'channel').map((c) => c.conversation_id),
407
+ convos.filter((c) => this.isSubscribableConversationType(c.type)).map((c) => c.conversation_id),
333
408
  );
334
409
  for (const convId of subscribableIds) {
335
410
  if (!this.stoppedConversations.has(convId) && !this.connections.has(convId)) {