@pingagent/sdk 0.1.3 → 0.1.5

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
@@ -67,7 +67,18 @@ declare class HistoryManager {
67
67
  limit?: number;
68
68
  beforeSeq?: number;
69
69
  afterSeq?: number;
70
+ beforeTsMs?: number;
71
+ afterTsMs?: number;
70
72
  }): StoredMessage[];
73
+ /**
74
+ * List the most recent N messages *before* a seq (paging older history).
75
+ * Only compares rows where seq is not null.
76
+ */
77
+ listBeforeSeq(conversationId: string, beforeSeq: number, limit: number): StoredMessage[];
78
+ /**
79
+ * List the most recent N messages *before* a timestamp (paging older history).
80
+ */
81
+ listBeforeTs(conversationId: string, beforeTsMs: number, limit: number): StoredMessage[];
71
82
  /** Returns the N most recent messages (chronological order, oldest to newest of those N). */
72
83
  listRecent(conversationId: string, limit: number): StoredMessage[];
73
84
  search(query: string, opts?: {
@@ -477,6 +488,25 @@ interface WsSubscriptionOptions {
477
488
  onError?: (err: Error) => void;
478
489
  /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
479
490
  onOpen?: (conversationId: string) => void;
491
+ /**
492
+ * Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
493
+ * This is client-side only (WS ping/pong control frames) and does not add inbox HTTP fetch cost.
494
+ */
495
+ heartbeat?: {
496
+ enable?: boolean;
497
+ /** If idle time >= this value, start pinging. */
498
+ idleThresholdMs?: number;
499
+ /** Minimum interval between pings while connection is idle. */
500
+ pingIntervalMs?: number;
501
+ /** How long to wait for pong after sending ping. If timeout, close the socket. */
502
+ pongTimeoutMs?: number;
503
+ /** Max consecutive missed pongs before closing. */
504
+ maxMissedPongs?: number;
505
+ /** Heartbeat scheduler tick frequency. */
506
+ tickMs?: number;
507
+ /** Jitter for ping interval to avoid synchronized bursts across many connections. */
508
+ jitter?: number;
509
+ };
480
510
  }
481
511
  declare class WsSubscription {
482
512
  private opts;
@@ -487,6 +517,8 @@ declare class WsSubscription {
487
517
  private stopped;
488
518
  /** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
489
519
  private stoppedConversations;
520
+ private heartbeatTimer;
521
+ private heartbeatStates;
490
522
  constructor(opts: WsSubscriptionOptions);
491
523
  start(): void;
492
524
  stop(): void;
@@ -497,6 +529,7 @@ declare class WsSubscription {
497
529
  stopConversation(conversationId: string): void;
498
530
  private wsUrl;
499
531
  private connectAsync;
532
+ private heartbeatTick;
500
533
  private scheduleReconnect;
501
534
  private connectAll;
502
535
  private syncConnections;
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  loadIdentity,
17
17
  saveIdentity,
18
18
  updateStoredToken
19
- } from "./chunk-HMVPT5N4.js";
19
+ } from "./chunk-V4IHFEJC.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-HMVPT5N4.js";
8
+ } from "./chunk-V4IHFEJC.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.3",
3
+ "version": "0.1.5",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/history.ts CHANGED
@@ -82,7 +82,10 @@ export class HistoryManager {
82
82
  return count;
83
83
  }
84
84
 
85
- list(conversationId: string, opts?: { limit?: number; beforeSeq?: number; afterSeq?: number }): StoredMessage[] {
85
+ list(
86
+ conversationId: string,
87
+ opts?: { limit?: number; beforeSeq?: number; afterSeq?: number; beforeTsMs?: number; afterTsMs?: number },
88
+ ): StoredMessage[] {
86
89
  const conditions = ['conversation_id = ?'];
87
90
  const params: any[] = [conversationId];
88
91
 
@@ -94,6 +97,14 @@ export class HistoryManager {
94
97
  conditions.push('seq > ?');
95
98
  params.push(opts.afterSeq);
96
99
  }
100
+ if (opts?.beforeTsMs !== undefined) {
101
+ conditions.push('ts_ms < ?');
102
+ params.push(opts.beforeTsMs);
103
+ }
104
+ if (opts?.afterTsMs !== undefined) {
105
+ conditions.push('ts_ms > ?');
106
+ params.push(opts.afterTsMs);
107
+ }
97
108
 
98
109
  const limit = opts?.limit ?? 100;
99
110
  const rows = this.store.getDb()
@@ -102,6 +113,37 @@ export class HistoryManager {
102
113
  return rows.map(rowToMessage);
103
114
  }
104
115
 
116
+ /**
117
+ * List the most recent N messages *before* a seq (paging older history).
118
+ * Only compares rows where seq is not null.
119
+ */
120
+ listBeforeSeq(conversationId: string, beforeSeq: number, limit: number): StoredMessage[] {
121
+ const rows = this.store.getDb()
122
+ .prepare(
123
+ `SELECT * FROM messages
124
+ WHERE conversation_id = ? AND seq IS NOT NULL AND seq < ?
125
+ ORDER BY seq DESC
126
+ LIMIT ?`,
127
+ )
128
+ .all(conversationId, beforeSeq, limit) as MessageRow[];
129
+ return rows.map(rowToMessage).reverse();
130
+ }
131
+
132
+ /**
133
+ * List the most recent N messages *before* a timestamp (paging older history).
134
+ */
135
+ listBeforeTs(conversationId: string, beforeTsMs: number, limit: number): StoredMessage[] {
136
+ const rows = this.store.getDb()
137
+ .prepare(
138
+ `SELECT * FROM messages
139
+ WHERE conversation_id = ? AND ts_ms < ?
140
+ ORDER BY ts_ms DESC
141
+ LIMIT ?`,
142
+ )
143
+ .all(conversationId, beforeTsMs, limit) as MessageRow[];
144
+ return rows.map(rowToMessage).reverse();
145
+ }
146
+
105
147
  /** Returns the N most recent messages (chronological order, oldest to newest of those N). */
106
148
  listRecent(conversationId: string, limit: number): StoredMessage[] {
107
149
  const rows = this.store.getDb()
@@ -21,6 +21,26 @@ export interface WsSubscriptionOptions {
21
21
  onError?: (err: Error) => void;
22
22
  /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
23
23
  onOpen?: (conversationId: string) => void;
24
+
25
+ /**
26
+ * Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
27
+ * This is client-side only (WS ping/pong control frames) and does not add inbox HTTP fetch cost.
28
+ */
29
+ heartbeat?: {
30
+ enable?: boolean;
31
+ /** If idle time >= this value, start pinging. */
32
+ idleThresholdMs?: number;
33
+ /** Minimum interval between pings while connection is idle. */
34
+ pingIntervalMs?: number;
35
+ /** How long to wait for pong after sending ping. If timeout, close the socket. */
36
+ pongTimeoutMs?: number;
37
+ /** Max consecutive missed pongs before closing. */
38
+ maxMissedPongs?: number;
39
+ /** Heartbeat scheduler tick frequency. */
40
+ tickMs?: number;
41
+ /** Jitter for ping interval to avoid synchronized bursts across many connections. */
42
+ jitter?: number;
43
+ };
24
44
  }
25
45
 
26
46
  const RECONNECT_BASE_MS = 1000;
@@ -28,6 +48,16 @@ const RECONNECT_MAX_MS = 30_000;
28
48
  const RECONNECT_JITTER = 0.2;
29
49
  const LIST_CONVERSATIONS_INTERVAL_MS = 60_000;
30
50
 
51
+ const DEFAULT_HEARTBEAT = {
52
+ enable: true,
53
+ idleThresholdMs: 25_000,
54
+ pingIntervalMs: 30_000,
55
+ pongTimeoutMs: 10_000,
56
+ maxMissedPongs: 2,
57
+ tickMs: 5_000,
58
+ jitter: 0.2,
59
+ };
60
+
31
61
  export class WsSubscription {
32
62
  private opts: WsSubscriptionOptions;
33
63
  private connections = new Map<string, WebSocket>();
@@ -38,6 +68,18 @@ export class WsSubscription {
38
68
  /** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
39
69
  private stoppedConversations = new Set<string>();
40
70
 
71
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
72
+ private heartbeatStates = new Map<
73
+ string,
74
+ {
75
+ lastRxAt: number;
76
+ lastPingAt: number;
77
+ nextPingAt: number;
78
+ missedPongs: number;
79
+ pongTimeout: ReturnType<typeof setTimeout> | null;
80
+ }
81
+ >();
82
+
41
83
  constructor(opts: WsSubscriptionOptions) {
42
84
  this.opts = opts;
43
85
  }
@@ -46,6 +88,12 @@ export class WsSubscription {
46
88
  this.stopped = false;
47
89
  this.connectAll();
48
90
  this.listInterval = setInterval(() => this.syncConnections(), LIST_CONVERSATIONS_INTERVAL_MS);
91
+
92
+ // Start heartbeat scheduler (idle-triggered) once per process.
93
+ const hb = this.opts.heartbeat ?? {};
94
+ if (hb.enable ?? DEFAULT_HEARTBEAT.enable) {
95
+ this.heartbeatTimer = setInterval(() => this.heartbeatTick(), hb.tickMs ?? DEFAULT_HEARTBEAT.tickMs);
96
+ }
49
97
  }
50
98
 
51
99
  stop(): void {
@@ -54,6 +102,10 @@ export class WsSubscription {
54
102
  clearInterval(this.listInterval);
55
103
  this.listInterval = null;
56
104
  }
105
+ if (this.heartbeatTimer) {
106
+ clearInterval(this.heartbeatTimer);
107
+ this.heartbeatTimer = null;
108
+ }
57
109
  for (const timer of this.reconnectTimers.values()) {
58
110
  clearTimeout(timer);
59
111
  }
@@ -67,6 +119,11 @@ export class WsSubscription {
67
119
  }
68
120
  this.reconnectAttempts.clear();
69
121
  this.stoppedConversations.clear();
122
+
123
+ for (const state of this.heartbeatStates.values()) {
124
+ if (state.pongTimeout) clearTimeout(state.pongTimeout);
125
+ }
126
+ this.heartbeatStates.clear();
70
127
  }
71
128
 
72
129
  /**
@@ -80,6 +137,11 @@ export class WsSubscription {
80
137
  clearTimeout(timer);
81
138
  this.reconnectTimers.delete(conversationId);
82
139
  }
140
+
141
+ const hbState = this.heartbeatStates.get(conversationId);
142
+ if (hbState?.pongTimeout) clearTimeout(hbState.pongTimeout);
143
+ this.heartbeatStates.delete(conversationId);
144
+
83
145
  const ws = this.connections.get(conversationId);
84
146
  if (ws) {
85
147
  ws.removeAllListeners();
@@ -106,6 +168,19 @@ export class WsSubscription {
106
168
 
107
169
  this.connections.set(conversationId, ws);
108
170
 
171
+ const hb = this.opts.heartbeat ?? {};
172
+ const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
173
+ if (hbEnabled) {
174
+ const now = Date.now();
175
+ this.heartbeatStates.set(conversationId, {
176
+ lastRxAt: now,
177
+ lastPingAt: 0,
178
+ nextPingAt: now + (hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs),
179
+ missedPongs: 0,
180
+ pongTimeout: null,
181
+ });
182
+ }
183
+
109
184
  ws.on('open', () => {
110
185
  this.reconnectAttempts.set(conversationId, 0);
111
186
  this.opts.onOpen?.(conversationId);
@@ -115,6 +190,18 @@ export class WsSubscription {
115
190
  ws.on('message', (data: Buffer | string) => {
116
191
  try {
117
192
  const msg = JSON.parse(data.toString());
193
+
194
+ // Any server frame counts as activity; keep connection stable and avoid unnecessary pings.
195
+ const state = this.heartbeatStates.get(conversationId);
196
+ if (state) {
197
+ state.lastRxAt = Date.now();
198
+ state.missedPongs = 0;
199
+ if (state.pongTimeout) {
200
+ clearTimeout(state.pongTimeout);
201
+ state.pongTimeout = null;
202
+ }
203
+ }
204
+
118
205
  if (msg.type === 'ws_message' && msg.envelope) {
119
206
  const env = msg.envelope;
120
207
  if (env.sender_did !== this.opts.myDid) {
@@ -128,8 +215,22 @@ export class WsSubscription {
128
215
  }
129
216
  });
130
217
 
218
+ ws.on('pong', () => {
219
+ const state = this.heartbeatStates.get(conversationId);
220
+ if (!state) return;
221
+ state.lastRxAt = Date.now();
222
+ state.missedPongs = 0;
223
+ if (state.pongTimeout) {
224
+ clearTimeout(state.pongTimeout);
225
+ state.pongTimeout = null;
226
+ }
227
+ });
228
+
131
229
  ws.on('close', () => {
132
230
  this.connections.delete(conversationId);
231
+ const state = this.heartbeatStates.get(conversationId);
232
+ if (state?.pongTimeout) clearTimeout(state.pongTimeout);
233
+ this.heartbeatStates.delete(conversationId);
133
234
  if (!this.stopped && !this.stoppedConversations.has(conversationId)) {
134
235
  this.scheduleReconnect(conversationId);
135
236
  }
@@ -140,6 +241,57 @@ export class WsSubscription {
140
241
  });
141
242
  }
142
243
 
244
+ private heartbeatTick(): void {
245
+ const hb = this.opts.heartbeat ?? {};
246
+ const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
247
+ if (!hbEnabled) return;
248
+
249
+ const idleThresholdMs = hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs;
250
+ const pingIntervalMs = hb.pingIntervalMs ?? DEFAULT_HEARTBEAT.pingIntervalMs;
251
+ const pongTimeoutMs = hb.pongTimeoutMs ?? DEFAULT_HEARTBEAT.pongTimeoutMs;
252
+ const maxMissedPongs = hb.maxMissedPongs ?? DEFAULT_HEARTBEAT.maxMissedPongs;
253
+ const jitter = hb.jitter ?? DEFAULT_HEARTBEAT.jitter;
254
+
255
+ const now = Date.now();
256
+
257
+ for (const [conversationId, ws] of this.connections) {
258
+ if (ws.readyState !== WebSocket.OPEN) continue;
259
+ const state = this.heartbeatStates.get(conversationId);
260
+ if (!state) continue;
261
+
262
+ // If we are waiting for a pong, do not send another ping.
263
+ if (state.pongTimeout) continue;
264
+
265
+ const idleFor = now - state.lastRxAt;
266
+ if (idleFor < idleThresholdMs) continue;
267
+
268
+ if (now < state.nextPingAt) continue;
269
+ if (state.lastPingAt !== 0 && now - state.lastPingAt < pingIntervalMs * 0.5) {
270
+ // Extra guard against edge cases (should be rare).
271
+ continue;
272
+ }
273
+
274
+ ws.ping();
275
+ state.lastPingAt = now;
276
+ const jitterFactor = 1 + (Math.random() - 0.5) * 2 * jitter;
277
+ state.nextPingAt = now + Math.max(1000, pingIntervalMs * jitterFactor);
278
+
279
+ state.pongTimeout = setTimeout(() => {
280
+ state.missedPongs += 1;
281
+ state.pongTimeout = null;
282
+
283
+ if (state.missedPongs > maxMissedPongs) {
284
+ try {
285
+ // Trigger close handler -> scheduleReconnect
286
+ ws.close(1001, 'pong timeout');
287
+ } catch {
288
+ // ignore
289
+ }
290
+ }
291
+ }, pongTimeoutMs);
292
+ }
293
+ }
294
+
143
295
  private scheduleReconnect(conversationId: string): void {
144
296
  if (this.reconnectTimers.has(conversationId)) return;
145
297