@butlerbot/sdk 0.0.25 → 0.0.26

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.
@@ -56,7 +56,11 @@ export type LinkOptions = {
56
56
  reconnect?: boolean;
57
57
  minReconnectDelayMs?: number;
58
58
  maxReconnectDelayMs?: number;
59
- /** Keeps idle connections alive through proxies. 0 disables. Default 30s. */
59
+ /**
60
+ * How long the connection may be silent before it is pinged, which both proves it is
61
+ * alive and keeps proxies from dropping it. A connection that is receiving frames is
62
+ * never pinged. 0 disables. Default 30s.
63
+ */
60
64
  heartbeatMs?: number;
61
65
  /** How long to wait for an acknowledgement. Turns are never timed out here. */
62
66
  requestTimeoutMs?: number;
@@ -112,6 +116,13 @@ export declare class Link {
112
116
  private reconnectAttempt;
113
117
  private reconnectAfterMs;
114
118
  private heartbeat?;
119
+ /**
120
+ * When the server last said anything at all.
121
+ *
122
+ * Any frame is proof the connection is alive, so a link that is busy carrying a turn
123
+ * never needs to ask.
124
+ */
125
+ private lastInboundAt;
115
126
  private closedByUs;
116
127
  constructor(options: LinkOptions);
117
128
  /** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
@@ -185,21 +196,31 @@ export declare class Link {
185
196
  private scheduleReconnect;
186
197
  private clearReconnect;
187
198
  /**
188
- * Pings on an interval and, the important half, notices when a ping goes unanswered.
199
+ * Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
189
200
  *
190
201
  * A websocket can die without a close frame — a dropped route, a proxy that forgets
191
202
  * the connection, a suspended machine — leaving both ends convinced they are
192
203
  * connected while every frame sent into it vanishes. An unanswered ping is the only
193
204
  * evidence this end will ever get, so it is treated as a dead connection and
194
205
  * reconnected rather than swallowed.
195
- */
196
- private startHeartbeat;
197
- /**
198
- * Never longer than the interval itself: a second ping in flight tells us nothing new.
199
206
  *
200
- * Floored so that a very short interval cannot declare a merely busy connection dead.
207
+ * It only asks when nothing has arrived for a whole interval, because a connection
208
+ * that is delivering frames has already answered the question. Pinging regardless
209
+ * meant a busy link had to complete a round trip while the socket was carrying a
210
+ * streaming turn: the reply queues behind everything already in flight, and a turn
211
+ * big enough to take longer than the timeout to drain got its own connection torn
212
+ * down with `4000 heartbeat timeout` — always mid-response, always on the longest
213
+ * answers, which are the ones a user least wants to lose.
201
214
  */
215
+ private startHeartbeat;
216
+ /** Wakes when the connection will have been silent for a full interval, not before. */
217
+ private scheduleHeartbeat;
218
+ /** One liveness round trip. Only ever sent to a connection that has gone quiet. */
219
+ private ping;
220
+ /** Never longer than the interval itself: a second ping in flight tells us nothing new. */
202
221
  private heartbeatTimeoutMs;
222
+ /** Any frame from the server, of any kind, is proof the connection still works. */
223
+ private markInbound;
203
224
  private stopHeartbeat;
204
225
  /** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
205
226
  private wait;
package/dist/link/link.js CHANGED
@@ -49,6 +49,13 @@ class Link {
49
49
  this.openWaiters = [];
50
50
  this.reconnectAttempt = 0;
51
51
  this.reconnectAfterMs = 0;
52
+ /**
53
+ * When the server last said anything at all.
54
+ *
55
+ * Any frame is proof the connection is alive, so a link that is busy carrying a turn
56
+ * never needs to ask.
57
+ */
58
+ this.lastInboundAt = 0;
52
59
  this.closedByUs = false;
53
60
  this.options = {
54
61
  ...DEFAULTS,
@@ -368,46 +375,82 @@ class Link {
368
375
  this.reconnectTimer = undefined;
369
376
  }
370
377
  /**
371
- * Pings on an interval and, the important half, notices when a ping goes unanswered.
378
+ * Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
372
379
  *
373
380
  * A websocket can die without a close frame — a dropped route, a proxy that forgets
374
381
  * the connection, a suspended machine — leaving both ends convinced they are
375
382
  * connected while every frame sent into it vanishes. An unanswered ping is the only
376
383
  * evidence this end will ever get, so it is treated as a dead connection and
377
384
  * reconnected rather than swallowed.
385
+ *
386
+ * It only asks when nothing has arrived for a whole interval, because a connection
387
+ * that is delivering frames has already answered the question. Pinging regardless
388
+ * meant a busy link had to complete a round trip while the socket was carrying a
389
+ * streaming turn: the reply queues behind everything already in flight, and a turn
390
+ * big enough to take longer than the timeout to drain got its own connection torn
391
+ * down with `4000 heartbeat timeout` — always mid-response, always on the longest
392
+ * answers, which are the ones a user least wants to lose.
378
393
  */
379
394
  startHeartbeat(generation) {
380
395
  if (!this.options.heartbeatMs)
381
396
  return;
382
397
  this.stopHeartbeat();
383
- this.heartbeat = setInterval(() => {
398
+ this.markInbound();
399
+ this.scheduleHeartbeat(generation);
400
+ }
401
+ /** Wakes when the connection will have been silent for a full interval, not before. */
402
+ scheduleHeartbeat(generation) {
403
+ if (generation !== this.generation || !this.options.heartbeatMs)
404
+ return;
405
+ const silentFor = Date.now() - this.lastInboundAt;
406
+ const due = Math.max(this.options.heartbeatMs - silentFor, 0);
407
+ this.heartbeat = setTimeout(() => {
384
408
  if (generation !== this.generation)
385
409
  return;
386
- // The reply is consumed by the exchange, so it never reaches log listeners.
387
- this.exchange("ping", {}, {
388
- awaitReady: false,
389
- isDone: (frame) => frame.type === "log",
390
- timeoutMs: this.heartbeatTimeoutMs(),
391
- }).catch(() => {
392
- if (generation !== this.generation)
393
- return;
394
- this.debug("heartbeat went unanswered, treating the connection as dead");
395
- this.dropSocket(generation, 4000, "heartbeat timeout");
396
- });
397
- }, this.options.heartbeatMs);
410
+ // Something arrived while this was pending: the connection is demonstrably
411
+ // alive and there is nothing to ask. Wait out the rest of its silence instead.
412
+ if (Date.now() - this.lastInboundAt < this.options.heartbeatMs) {
413
+ return this.scheduleHeartbeat(generation);
414
+ }
415
+ this.ping(generation);
416
+ }, due);
398
417
  unref(this.heartbeat);
399
418
  }
400
- /**
401
- * Never longer than the interval itself: a second ping in flight tells us nothing new.
402
- *
403
- * Floored so that a very short interval cannot declare a merely busy connection dead.
404
- */
419
+ /** One liveness round trip. Only ever sent to a connection that has gone quiet. */
420
+ ping(generation) {
421
+ const sentAt = Date.now();
422
+ // The reply is consumed by the exchange, so it never reaches log listeners.
423
+ this.exchange("ping", {}, {
424
+ awaitReady: false,
425
+ isDone: (frame) => frame.type === "log",
426
+ timeoutMs: this.heartbeatTimeoutMs(),
427
+ }).then(() => {
428
+ this.scheduleHeartbeat(generation);
429
+ }, () => {
430
+ if (generation !== this.generation)
431
+ return;
432
+ // The pong is late but other frames are flowing, so the socket is fine and only
433
+ // this reply is stuck behind them. Killing it here would throw away a working
434
+ // connection — and whatever it was busy delivering.
435
+ if (this.lastInboundAt > sentAt) {
436
+ this.debug("heartbeat was slow but the connection is delivering frames, keeping it");
437
+ return this.scheduleHeartbeat(generation);
438
+ }
439
+ this.debug("heartbeat went unanswered, treating the connection as dead");
440
+ this.dropSocket(generation, 4000, "heartbeat timeout");
441
+ });
442
+ }
443
+ /** Never longer than the interval itself: a second ping in flight tells us nothing new. */
405
444
  heartbeatTimeoutMs() {
406
445
  return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
407
446
  }
447
+ /** Any frame from the server, of any kind, is proof the connection still works. */
448
+ markInbound() {
449
+ this.lastInboundAt = Date.now();
450
+ }
408
451
  stopHeartbeat() {
409
452
  if (this.heartbeat)
410
- clearInterval(this.heartbeat);
453
+ clearTimeout(this.heartbeat);
411
454
  this.heartbeat = undefined;
412
455
  }
413
456
  // =============================================
@@ -585,6 +628,7 @@ class Link {
585
628
  this.calls.clear();
586
629
  }
587
630
  onMessage(raw) {
631
+ this.markInbound();
588
632
  let frame;
589
633
  try {
590
634
  frame = JSON.parse(raw);
@@ -105,6 +105,21 @@ export type LinkClientPayloads = {
105
105
  "conversation.end": {
106
106
  sessionId: string;
107
107
  };
108
+ /**
109
+ * Watches a turn that is already running, without starting one.
110
+ *
111
+ * A turn belongs to the conversation, not to the socket that asked for it, so a
112
+ * client that reloads or opens the conversation elsewhere can pick the answer back
113
+ * up as it is written. `afterEventId` resumes from what the client already has.
114
+ */
115
+ "conversation.attach": {
116
+ chatId: string;
117
+ afterEventId?: string;
118
+ };
119
+ /** Stops watching. The turn itself keeps running. */
120
+ "conversation.detach": {
121
+ chatId: string;
122
+ };
108
123
  };
109
124
  export type LinkClientFrameType = keyof LinkClientPayloads;
110
125
  /**
@@ -167,6 +182,7 @@ export type LinkServerPayloads = {
167
182
  };
168
183
  "conversation.event": {
169
184
  chatId?: string;
185
+ eventId?: string;
170
186
  event: ConversationEvent;
171
187
  };
172
188
  "conversation.notice": {
@@ -137,8 +137,20 @@ export declare class Conversation<V extends APIPath = "v4"> {
137
137
  onceConvoId(cb: (convoId: string) => any): string | undefined;
138
138
  /** Fetches the conversation state from the server, including message history and metadata */
139
139
  fetchState(): Promise<ConversationStateResponse>;
140
- /** Fetches the conversation progress stream from the server */
141
- fetchProgressStream(cb: (chunk: RequestResponseByVersion[V]) => any): EventSource;
140
+ /**
141
+ * Follows the turn currently running in this conversation.
142
+ *
143
+ * Reopening a conversation mid-answer is watching a turn, not starting one, and a
144
+ * turn belongs to the conversation rather than to whoever started it. When the
145
+ * conversation is carried over a websocket Link this rides that same connection;
146
+ * otherwise it opens the HTTP progress stream. Either way the payloads are the same.
147
+ *
148
+ * `afterEventId` resumes from what the caller already has, so a client that reloads
149
+ * is sent what it missed rather than the turn from the beginning.
150
+ */
151
+ fetchProgressStream(cb: (chunk: RequestResponseByVersion[V]) => any, options?: {
152
+ afterEventId?: string;
153
+ }): ConversationStream | EventSource;
142
154
  /**
143
155
  * Fetches the conversation progress from the server
144
156
  * Returns undefined if no active turn progress
@@ -176,10 +176,29 @@ class Conversation {
176
176
  const data = await response.json();
177
177
  return data;
178
178
  }
179
- /** Fetches the conversation progress stream from the server */
180
- fetchProgressStream(cb) {
179
+ /**
180
+ * Follows the turn currently running in this conversation.
181
+ *
182
+ * Reopening a conversation mid-answer is watching a turn, not starting one, and a
183
+ * turn belongs to the conversation rather than to whoever started it. When the
184
+ * conversation is carried over a websocket Link this rides that same connection;
185
+ * otherwise it opens the HTTP progress stream. Either way the payloads are the same.
186
+ *
187
+ * `afterEventId` resumes from what the caller already has, so a client that reloads
188
+ * is sent what it missed rather than the turn from the beginning.
189
+ */
190
+ fetchProgressStream(cb, options) {
181
191
  if (!this.convoId)
182
192
  throw new Error("Conversation ID is not set");
193
+ if (this.transport.attach) {
194
+ return this.transport.attach({
195
+ chatId: this.convoId,
196
+ ...(options?.afterEventId ? { afterEventId: options.afterEventId } : {}),
197
+ }, {
198
+ payload: (payload) => cb(payload),
199
+ convoId: () => { },
200
+ });
201
+ }
183
202
  const url = (0, url_formatter_1.formatURL)(this.endpoints.progressStream, { chatId: this.convoId }, { apiKey: this.apiKey, debug: this.debug });
184
203
  return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload: (payload) => cb(payload) });
185
204
  }
@@ -30,8 +30,21 @@ export type TransportHandlers = {
30
30
  /** The conversation this turn belongs to, as soon as it is known. */
31
31
  convoId(convoId: string): void;
32
32
  };
33
+ /** A request to watch a turn that is already running in a conversation. */
34
+ export type TransportAttachRequest = {
35
+ chatId: string;
36
+ /** Resume point: only what came after this event is replayed. */
37
+ afterEventId?: string;
38
+ };
33
39
  export interface ConversationTransport {
34
40
  send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
41
+ /**
42
+ * Watches a turn that is already running, without starting one.
43
+ *
44
+ * Optional: a transport that cannot follow someone else's turn simply does not
45
+ * implement it, and the conversation falls back to the HTTP progress stream.
46
+ */
47
+ attach?(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
35
48
  }
36
49
  /**
37
50
  * The system message form the HTTP transport uses for a notice.
@@ -1,5 +1,5 @@
1
1
  import type { Link } from "../link/link";
2
- import { ConversationStream, ConversationTransport, TransportHandlers, TransportTurnRequest } from "./transport";
2
+ import { ConversationStream, ConversationTransport, TransportAttachRequest, TransportHandlers, TransportTurnRequest } from "./transport";
3
3
  export type LinkSessionConfig = {
4
4
  model?: string;
5
5
  personality?: string;
@@ -22,6 +22,18 @@ export declare class LinkConversationTransport implements ConversationTransport
22
22
  send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
23
23
  /** Ends the session, if one is open. The conversation can still be resumed later. */
24
24
  end(): Promise<void>;
25
+ /**
26
+ * Follows a turn that is already running, over the link this conversation already holds.
27
+ *
28
+ * A turn belongs to the conversation rather than to the socket that started it, so
29
+ * reopening a conversation mid-answer — a reload, a second tab, a turn started from
30
+ * another device or over HTTP — streams here instead of dropping to an SSE connection
31
+ * just to watch. Needs no session: watching is not speaking.
32
+ *
33
+ * A conversation with nothing running ends the stream immediately, which is the
34
+ * ordinary answer for one that is simply idle.
35
+ */
36
+ attach(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
25
37
  private runTurn;
26
38
  /** Opens a session, or reuses the open one when it is for the same conversation. */
27
39
  private session;
@@ -41,6 +41,79 @@ class LinkConversationTransport {
41
41
  this.sessionId = undefined;
42
42
  await this.link.exchange("conversation.end", { sessionId });
43
43
  }
44
+ /**
45
+ * Follows a turn that is already running, over the link this conversation already holds.
46
+ *
47
+ * A turn belongs to the conversation rather than to the socket that started it, so
48
+ * reopening a conversation mid-answer — a reload, a second tab, a turn started from
49
+ * another device or over HTTP — streams here instead of dropping to an SSE connection
50
+ * just to watch. Needs no session: watching is not speaking.
51
+ *
52
+ * A conversation with nothing running ends the stream immediately, which is the
53
+ * ordinary answer for one that is simply idle.
54
+ */
55
+ attach(request, handlers) {
56
+ let closed = false;
57
+ const deliver = (payload) => { if (!closed)
58
+ handlers.payload(payload); };
59
+ const watching = this.link.exchange("conversation.attach", {
60
+ chatId: request.chatId,
61
+ ...(request.afterEventId ? { afterEventId: request.afterEventId } : {}),
62
+ }, {
63
+ // A turn takes as long as it takes; only the transport dying ends it early.
64
+ timeoutMs: 0,
65
+ isDone: (frame) => frame.type === "conversation.done",
66
+ onFrame: (frame) => {
67
+ if (frame.type === "conversation.event") {
68
+ const payload = frame.payload;
69
+ const event = payload.event;
70
+ const final = event.type === "response_status" && Boolean(event.payload?.completed);
71
+ deliver({
72
+ success: true,
73
+ data: {
74
+ response: event,
75
+ convoId: payload.chatId ?? request.chatId,
76
+ ...(final ? { quitStream: true } : {}),
77
+ },
78
+ });
79
+ return;
80
+ }
81
+ if (frame.type === "conversation.notice") {
82
+ deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? request.chatId));
83
+ }
84
+ },
85
+ });
86
+ void watching.then((done) => {
87
+ const payload = done.payload;
88
+ if (payload.ok)
89
+ return;
90
+ // Nothing running is not a failure: the caller asked to watch a conversation
91
+ // that has nothing to watch, and the stream simply ends.
92
+ if (payload.code === "no_active_turn")
93
+ return;
94
+ deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be watched.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? request.chatId));
95
+ }, (error) => {
96
+ if (error instanceof protocol_1.LinkError && error.code === "no_active_turn")
97
+ return;
98
+ deliver(error instanceof protocol_1.LinkError
99
+ ? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
100
+ : (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId));
101
+ });
102
+ return {
103
+ close: () => {
104
+ if (closed)
105
+ return;
106
+ closed = true;
107
+ // Best-effort: a socket that has gone has already ended the watch for us.
108
+ try {
109
+ this.link.send("conversation.detach", { chatId: request.chatId });
110
+ }
111
+ catch {
112
+ // Disconnected. The server drops the attachment with the connection.
113
+ }
114
+ },
115
+ };
116
+ }
44
117
  async runTurn(request, handlers, mayRetry) {
45
118
  const sessionId = await this.session(request);
46
119
  let chatId = request.chatId ?? this.sessionChatId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -1,20 +1,18 @@
1
1
  # ButlerBot SDK
2
2
 
3
- ButlerBot SDK is a JavaScript library that provides a simple way to interact with the [ButlerBot](https://butler.now/) API.
3
+ ButlerBot SDK is a JavaScript library that provides a simple way to interact with the [Butler](https://butler.now/) API.
4
4
 
5
5
  ## Quickstart
6
6
 
7
- Grab an API key at [ButlerBot](https://butler.now/) and install the package:
7
+ Grab an API key at [Butler](https://butler.now/) (Dashboard -> Account Dropdown -> API Keys) and install the package:
8
8
 
9
9
  ```bash
10
10
  npm i @butlerbot/sdk
11
11
  ```
12
12
 
13
- > NOTE: API key is currently not available on the ButlerBot UI
14
-
15
13
  ## Prerequisites
16
14
 
17
- - ButlerBot API key
15
+ - Butler API key
18
16
 
19
17
  ## Talking to Alfred
20
18
 
@@ -212,13 +210,27 @@ Which to use:
212
210
  Best when you are already running a link for tools or hooks, or holding many
213
211
  conversations at once — one socket carries them all.
214
212
 
215
- Two differences to know about:
213
+ One difference to know about: sessions are ephemeral. If the connection drops mid-turn
214
+ the SDK reopens the session and resends transparently; the conversation itself is
215
+ persisted server-side, so nothing is lost.
216
+
217
+ ### Rejoining a turn already in progress
218
+
219
+ A turn belongs to the conversation, not to whoever started it, so reopening a
220
+ conversation mid-answer picks the reply back up as it is written:
221
+
222
+ ```typescript
223
+ const convo = client.createConversation({ convoId, transport: link });
224
+ const watching = convo.fetchProgressStream(chunk => render(chunk));
225
+ // ...later
226
+ watching.close(); // stop watching; the turn keeps running
227
+ ```
216
228
 
217
- - Sessions are ephemeral. If the connection drops mid-turn the SDK reopens the session
218
- and resends transparently; the conversation itself is persisted server-side, so
219
- nothing is lost.
220
- - The HTTP transport replays your own message back to you (it exists so a browser
221
- reconnecting mid-turn sees it). A Link does not, since it has nothing to replay.
229
+ Over a Link this rides the connection you already hold; over SSE it opens the HTTP
230
+ progress stream. The payloads are identical either way, including your own message and
231
+ the conversation's start — everything a client that arrived late needs to draw the turn
232
+ from the beginning. Pass `{ afterEventId }` to be sent only what you have not already
233
+ seen. A conversation with nothing running simply ends the stream.
222
234
 
223
235
  Neither transport can cancel a turn: `close()` stops delivery locally, and the reply is
224
236
  still generated and stored.