@butlerbot/sdk 0.0.24 → 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;
@@ -88,6 +92,8 @@ export declare class Link {
88
92
  private readonly pending;
89
93
  private readonly calls;
90
94
  private socket;
95
+ /** Every socket this link has opened and not yet closed, by generation. */
96
+ private readonly sockets;
91
97
  private frameCounter;
92
98
  private currentState;
93
99
  private identity?;
@@ -110,6 +116,13 @@ export declare class Link {
110
116
  private reconnectAttempt;
111
117
  private reconnectAfterMs;
112
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;
113
126
  private closedByUs;
114
127
  constructor(options: LinkOptions);
115
128
  /** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
@@ -162,6 +175,15 @@ export declare class Link {
162
175
  * means the real event, whenever it turns up, is ignored.
163
176
  */
164
177
  private dropSocket;
178
+ /**
179
+ * Closes a socket this end has stopped using, whatever generation it belongs to.
180
+ *
181
+ * Every path that walks away from a socket goes through here. Forgetting one is not harmless:
182
+ * the server has no way to tell an abandoned connection from a live one — it has said hello,
183
+ * registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
184
+ * hands the link id back and forth between it and its replacements.
185
+ */
186
+ private abandon;
165
187
  private onClose;
166
188
  /** Retries when it is allowed to, and tells everyone waiting when it is not. */
167
189
  private retryOrGiveUp;
@@ -174,21 +196,31 @@ export declare class Link {
174
196
  private scheduleReconnect;
175
197
  private clearReconnect;
176
198
  /**
177
- * 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.
178
200
  *
179
201
  * A websocket can die without a close frame — a dropped route, a proxy that forgets
180
202
  * the connection, a suspended machine — leaving both ends convinced they are
181
203
  * connected while every frame sent into it vanishes. An unanswered ping is the only
182
204
  * evidence this end will ever get, so it is treated as a dead connection and
183
205
  * reconnected rather than swallowed.
184
- */
185
- private startHeartbeat;
186
- /**
187
- * Never longer than the interval itself: a second ping in flight tells us nothing new.
188
206
  *
189
- * 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.
190
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. */
191
221
  private heartbeatTimeoutMs;
222
+ /** Any frame from the server, of any kind, is proof the connection still works. */
223
+ private markInbound;
192
224
  private stopHeartbeat;
193
225
  /** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
194
226
  private wait;
package/dist/link/link.js CHANGED
@@ -28,6 +28,8 @@ class Link {
28
28
  this.pending = new Map();
29
29
  this.calls = new Map();
30
30
  this.socket = null;
31
+ /** Every socket this link has opened and not yet closed, by generation. */
32
+ this.sockets = new Map();
31
33
  this.frameCounter = 0;
32
34
  this.currentState = "idle";
33
35
  /**
@@ -47,6 +49,13 @@ class Link {
47
49
  this.openWaiters = [];
48
50
  this.reconnectAttempt = 0;
49
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;
50
59
  this.closedByUs = false;
51
60
  this.options = {
52
61
  ...DEFAULTS,
@@ -171,7 +180,10 @@ class Link {
171
180
  this.failPending(closed);
172
181
  this.settleWaiters(this.attemptWaiters, closed);
173
182
  this.settleWaiters(this.openWaiters, closed);
174
- this.socket?.close(1000, reason);
183
+ // Every socket, not just the current one: a link that is being closed for good must not
184
+ // leave anything of itself attached to the server.
185
+ for (const generation of [...this.sockets.keys()])
186
+ this.abandon(generation, reason);
175
187
  this.socket = null;
176
188
  }
177
189
  /**
@@ -194,14 +206,23 @@ class Link {
194
206
  const { url, protocols } = (0, socket_1.buildHandshake)(this.options.serverUrl, "link", this.options.apiKey);
195
207
  this.debug(`connecting to ${url.replace(/api_key=[^&]+/, "api_key=***")}`);
196
208
  try {
197
- this.socket = this.options.socketFactory(url, protocols, {
209
+ const socket = this.options.socketFactory(url, protocols, {
198
210
  onOpen: () => this.onOpen(generation),
199
211
  onMessage: (data) => { if (generation === this.generation)
200
212
  this.onMessage(data); },
201
- onClose: (code, reason) => this.onClose(generation, code, reason),
213
+ onClose: (code, reason) => {
214
+ this.sockets.delete(generation);
215
+ this.onClose(generation, code, reason);
216
+ },
202
217
  onError: (error) => { if (generation === this.generation)
203
218
  this.emitter.emit("error", asError(error)); },
204
219
  });
220
+ // Held by generation, not only as `this.socket`, so a socket that stops being the
221
+ // current one can still be closed. One that is merely forgotten stays open at the far
222
+ // end: it has already said hello and registered its tools, so the server goes on serving
223
+ // it, and it answers websocket pings forever because the network stack does that for it.
224
+ this.sockets.set(generation, socket);
225
+ this.socket = socket;
205
226
  }
206
227
  catch (error) {
207
228
  // A factory that throws never produces a close event, so this failure is
@@ -230,9 +251,11 @@ class Link {
230
251
  const identity = { connectionId: welcome.connectionId, scope: welcome.scope };
231
252
  this.identity = identity;
232
253
  await this.registerAll();
233
- // Registration is several round trips; the socket may have gone during them.
254
+ // Registration is several round trips; the socket may have gone during them. It is fully
255
+ // registered at the server by now, so abandoning it quietly would leave it serving tools
256
+ // nothing here will ever answer.
234
257
  if (generation !== this.generation)
235
- return;
258
+ return this.abandon(generation, "superseded during handshake");
236
259
  this.attempting = false;
237
260
  this.currentState = "open";
238
261
  this.reconnectAttempt = 0;
@@ -243,7 +266,7 @@ class Link {
243
266
  this.emitter.emit("connect", identity);
244
267
  }).catch((error) => {
245
268
  if (generation !== this.generation)
246
- return;
269
+ return this.abandon(generation, "superseded during handshake");
247
270
  this.emitter.emit("error", error);
248
271
  this.settleWaiters(this.attemptWaiters, error);
249
272
  // Only the server saying "do not come back" stops us: a rejected claim or an
@@ -266,12 +289,28 @@ class Link {
266
289
  dropSocket(generation, code, reason) {
267
290
  if (generation !== this.generation)
268
291
  return;
269
- const socket = this.socket;
270
292
  this.onClose(generation, code, reason);
293
+ // 1006 is reserved and rejected by browsers; anything else we raise is a valid
294
+ // application code.
295
+ this.abandon(generation, reason, code === 1006 ? 1000 : code);
296
+ }
297
+ /**
298
+ * Closes a socket this end has stopped using, whatever generation it belongs to.
299
+ *
300
+ * Every path that walks away from a socket goes through here. Forgetting one is not harmless:
301
+ * the server has no way to tell an abandoned connection from a live one — it has said hello,
302
+ * registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
303
+ * hands the link id back and forth between it and its replacements.
304
+ */
305
+ abandon(generation, reason, code = 1000) {
306
+ const socket = this.sockets.get(generation);
307
+ if (!socket)
308
+ return;
309
+ this.sockets.delete(generation);
310
+ if (this.socket === socket)
311
+ this.socket = null;
271
312
  try {
272
- // 1006 is reserved and rejected by browsers; anything else we raise is a valid
273
- // application code.
274
- socket?.close(code === 1006 ? 1000 : code, reason);
313
+ socket.close(code, reason);
275
314
  }
276
315
  catch {
277
316
  // Already gone, which is the outcome we wanted anyway.
@@ -336,46 +375,82 @@ class Link {
336
375
  this.reconnectTimer = undefined;
337
376
  }
338
377
  /**
339
- * 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.
340
379
  *
341
380
  * A websocket can die without a close frame — a dropped route, a proxy that forgets
342
381
  * the connection, a suspended machine — leaving both ends convinced they are
343
382
  * connected while every frame sent into it vanishes. An unanswered ping is the only
344
383
  * evidence this end will ever get, so it is treated as a dead connection and
345
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.
346
393
  */
347
394
  startHeartbeat(generation) {
348
395
  if (!this.options.heartbeatMs)
349
396
  return;
350
397
  this.stopHeartbeat();
351
- 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(() => {
352
408
  if (generation !== this.generation)
353
409
  return;
354
- // The reply is consumed by the exchange, so it never reaches log listeners.
355
- this.exchange("ping", {}, {
356
- awaitReady: false,
357
- isDone: (frame) => frame.type === "log",
358
- timeoutMs: this.heartbeatTimeoutMs(),
359
- }).catch(() => {
360
- if (generation !== this.generation)
361
- return;
362
- this.debug("heartbeat went unanswered, treating the connection as dead");
363
- this.dropSocket(generation, 4000, "heartbeat timeout");
364
- });
365
- }, 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);
366
417
  unref(this.heartbeat);
367
418
  }
368
- /**
369
- * Never longer than the interval itself: a second ping in flight tells us nothing new.
370
- *
371
- * Floored so that a very short interval cannot declare a merely busy connection dead.
372
- */
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. */
373
444
  heartbeatTimeoutMs() {
374
445
  return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
375
446
  }
447
+ /** Any frame from the server, of any kind, is proof the connection still works. */
448
+ markInbound() {
449
+ this.lastInboundAt = Date.now();
450
+ }
376
451
  stopHeartbeat() {
377
452
  if (this.heartbeat)
378
- clearInterval(this.heartbeat);
453
+ clearTimeout(this.heartbeat);
379
454
  this.heartbeat = undefined;
380
455
  }
381
456
  // =============================================
@@ -553,6 +628,7 @@ class Link {
553
628
  this.calls.clear();
554
629
  }
555
630
  onMessage(raw) {
631
+ this.markInbound();
556
632
  let frame;
557
633
  try {
558
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.24",
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.